Compare commits
3 Commits
0ec7f7f08a
...
e661e15bbf
| Author | SHA1 | Date | |
|---|---|---|---|
| e661e15bbf | |||
| 2bee7dce43 | |||
| 8ab1efcca7 |
12
Provider/.agents/rules/how-to-write-tdd.md
Normal file
12
Provider/.agents/rules/how-to-write-tdd.md
Normal 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)
|
||||
@@ -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,11 +137,14 @@
|
||||
]);
|
||||
</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">
|
||||
<div class="app-layout">
|
||||
<Sidebar {currentView} onNavigate={(view) => currentView = view} />
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- Status Badge -->
|
||||
@@ -137,6 +173,41 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#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>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 2-Column Grid Layout -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 items-start">
|
||||
|
||||
@@ -247,6 +318,13 @@
|
||||
|
||||
</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">
|
||||
@@ -274,4 +352,25 @@
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</main>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: var(--color-bg-primary);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 16px 32px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
135
Provider/frontend/src/lib/Sidebar.svelte
Normal file
135
Provider/frontend/src/lib/Sidebar.svelte
Normal 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>
|
||||
541
Provider/frontend/src/lib/TaskManager.svelte
Normal file
541
Provider/frontend/src/lib/TaskManager.svelte
Normal 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>
|
||||
274
Provider/frontend/src/lib/UserManager.svelte
Normal file
274
Provider/frontend/src/lib/UserManager.svelte
Normal 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>
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"major": 0,
|
||||
"minor": 1,
|
||||
"revision": 14
|
||||
"revision": 4
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
76
Provider/main/api/tasks/add.cpp
Normal file
76
Provider/main/api/tasks/add.cpp
Normal 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};
|
||||
62
Provider/main/api/tasks/list.cpp
Normal file
62
Provider/main/api/tasks/list.cpp
Normal 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};
|
||||
42
Provider/main/api/tasks/remove.cpp
Normal file
42
Provider/main/api/tasks/remove.cpp
Normal 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};
|
||||
111
Provider/main/api/tasks/store.cpp
Normal file
111
Provider/main/api/tasks/store.cpp
Normal 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
|
||||
}
|
||||
12
Provider/main/api/tasks/store.hpp
Normal file
12
Provider/main/api/tasks/store.hpp
Normal 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();
|
||||
8
Provider/main/api/tasks/unity.cpp
Normal file
8
Provider/main/api/tasks/unity.cpp
Normal 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
|
||||
72
Provider/main/api/tasks/upcoming.cpp
Normal file
72
Provider/main/api/tasks/upcoming.cpp
Normal 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};
|
||||
77
Provider/main/api/tasks/update.cpp
Normal file
77
Provider/main/api/tasks/update.cpp
Normal 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};
|
||||
67
Provider/main/api/users/add.cpp
Normal file
67
Provider/main/api/users/add.cpp
Normal 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};
|
||||
41
Provider/main/api/users/list.cpp
Normal file
41
Provider/main/api/users/list.cpp
Normal 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};
|
||||
47
Provider/main/api/users/remove.cpp
Normal file
47
Provider/main/api/users/remove.cpp
Normal 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};
|
||||
56
Provider/main/api/users/store.cpp
Normal file
56
Provider/main/api/users/store.cpp
Normal 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");
|
||||
}
|
||||
11
Provider/main/api/users/store.hpp
Normal file
11
Provider/main/api/users/store.hpp
Normal 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();
|
||||
6
Provider/main/api/users/unity.cpp
Normal file
6
Provider/main/api/users/unity.cpp
Normal 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
|
||||
@@ -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 = "/*",
|
||||
|
||||
@@ -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
|
||||
{
|
||||
err = nvs_get_u8(my_handle, "www_part", &g_Active_WWW_Partition);
|
||||
if (err != ESP_OK && err != ESP_ERR_NVS_NOT_FOUND)
|
||||
else if (err != ESP_OK)
|
||||
{
|
||||
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
20
Provider/main/todo.hpp
Normal file
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "types.hpp"
|
||||
#include "user.hpp"
|
||||
|
||||
struct task_t
|
||||
{
|
||||
uint16 id; // Auto-assigned (1–65535, 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
16
Provider/main/user.hpp
Normal file
@@ -0,0 +1,16 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "types.hpp"
|
||||
|
||||
struct user_t
|
||||
{
|
||||
uint8 id; // Auto-assigned (1–255, 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
195
Provider/tdd/todo_list.md
Normal 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*
|
||||
Reference in New Issue
Block a user