feat: Implement core frontend application with task management, user selection, and a collapsible sidebar.

This commit is contained in:
2026-03-07 22:18:36 -05:00
parent 2bee7dce43
commit e661e15bbf
7 changed files with 189 additions and 70 deletions

View File

@@ -144,7 +144,7 @@
<div class="w-full max-w-6xl mx-auto space-y-8"> <div class="w-full max-w-6xl mx-auto space-y-8">
<!-- Header --> <!-- Header -->
<div class="text-center"> <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> <p class="text-text-secondary text-sm">ESP32-S3 System Dashboard v{__APP_VERSION__}</p>
<!-- Status Badge --> <!-- Status Badge -->
@@ -175,6 +175,39 @@
{#if currentView === 'dashboard'} {#if currentView === 'dashboard'}
<!-- Dashboard View --> <!-- 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 --> <!-- 2-Column Grid Layout -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 items-start"> <div class="grid grid-cols-1 md:grid-cols-2 gap-8 items-start">
@@ -285,38 +318,6 @@
</div> </div>
<!-- Upcoming Tasks Section -->
{#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}
{:else if currentView === 'tasks'} {:else if currentView === 'tasks'}
<!-- Task Manager View --> <!-- Task Manager View -->
<div class="bg-bg-card border border-border rounded-xl p-6 shadow-xl"> <div class="bg-bg-card border border-border rounded-xl p-6 shadow-xl">

View File

@@ -1,6 +1,6 @@
<script> <script>
let { currentView = 'dashboard', onNavigate = () => {} } = $props(); let { currentView = 'dashboard', onNavigate = () => {} } = $props();
let collapsed = $state(false); let collapsed = $state(typeof window !== 'undefined' && window.innerWidth <= 768);
const navItems = [ const navItems = [
{ id: 'dashboard', label: 'Dashboard', icon: '🏠' }, { id: 'dashboard', label: 'Dashboard', icon: '🏠' },

View File

@@ -8,13 +8,15 @@
// Add task form state // Add task form state
let newTitle = $state(''); let newTitle = $state('');
let newDueDate = $state(''); let newDueDay = $state('');
let newDueTime = $state('');
let showAddForm = $state(false); let showAddForm = $state(false);
// Edit state // Edit state
let editingTaskId = $state(null); let editingTaskId = $state(null);
let editTitle = $state(''); let editTitle = $state('');
let editDueDate = $state(''); let editDueDay = $state('');
let editDueTime = $state('');
// Confirm delete // Confirm delete
let confirmDeleteId = $state(null); let confirmDeleteId = $state(null);
@@ -45,13 +47,14 @@
async function handleAddTask(e) { async function handleAddTask(e) {
e.preventDefault(); e.preventDefault();
if (!newTitle.trim() || !newDueDate) return; if (!newTitle.trim() || !newDueDay || !newDueTime) return;
const dueTimestamp = Math.floor(new Date(newDueDate).getTime() / 1000); const dueTimestamp = Math.floor(new Date(`${newDueDay}T${newDueTime}`).getTime() / 1000);
try { try {
await addTask(selectedUserId, newTitle.trim(), dueTimestamp); await addTask(selectedUserId, newTitle.trim(), dueTimestamp);
newTitle = ''; newTitle = '';
newDueDate = ''; newDueDay = '';
newDueTime = '';
showAddForm = false; showAddForm = false;
await fetchTasks(); await fetchTasks();
} catch (e) { } catch (e) {
@@ -71,14 +74,16 @@
function startEditing(task) { function startEditing(task) {
editingTaskId = task.id; editingTaskId = task.id;
editTitle = task.title; editTitle = task.title;
editDueDate = formatDateForInput(task.due_date); const parts = formatDateForInput(task.due_date);
editDueDay = parts.day;
editDueTime = parts.time;
} }
async function saveEdit(e) { async function saveEdit(e) {
e.preventDefault(); e.preventDefault();
if (!editTitle.trim()) return; if (!editTitle.trim()) return;
const dueTimestamp = Math.floor(new Date(editDueDate).getTime() / 1000); const dueTimestamp = Math.floor(new Date(`${editDueDay}T${editDueTime}`).getTime() / 1000);
try { try {
await updateTask(editingTaskId, { await updateTask(editingTaskId, {
title: editTitle.trim(), title: editTitle.trim(),
@@ -103,7 +108,18 @@
function formatDateForInput(timestamp) { function formatDateForInput(timestamp) {
const d = new Date(timestamp * 1000); const d = new Date(timestamp * 1000);
return d.toISOString().slice(0, 16); // "YYYY-MM-DDTHH:mm" 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) { function formatRelativeDate(timestamp) {
@@ -135,12 +151,13 @@
<div class="task-header"> <div class="task-header">
<h2 class="task-title">Tasks</h2> <h2 class="task-title">Tasks</h2>
{#if !showAddForm} {#if !showAddForm}
<button class="add-task-btn" onclick={() => showAddForm = true}>+ Add Task</button> <button class="add-task-btn" onclick={() => { showAddForm = true; setNowForNewTask(); }}>+ Add Task</button>
{/if} {/if}
</div> </div>
{#if showAddForm} {#if showAddForm}
<form class="add-task-form" onsubmit={handleAddTask}> <form class="add-task-form" onsubmit={handleAddTask}>
<!-- svelte-ignore a11y_autofocus -->
<input <input
type="text" type="text"
bind:value={newTitle} bind:value={newTitle}
@@ -148,14 +165,19 @@
class="task-input" class="task-input"
autofocus autofocus
/> />
<input <div class="date-time-row">
type="datetime-local" <label class="date-time-field">
bind:value={newDueDate} <span class="field-label">Date</span>
class="task-date-input" <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"> <div class="form-actions">
<button type="submit" class="btn-primary" disabled={!newTitle.trim() || !newDueDate}>Add</button> <button type="submit" class="btn-primary" disabled={!newTitle.trim() || !newDueDay || !newDueTime}>Add</button>
<button type="button" class="btn-secondary" onclick={() => { showAddForm = false; newTitle = ''; newDueDate = ''; }}>Cancel</button> <button type="button" class="btn-secondary" onclick={() => { showAddForm = false; newTitle = ''; newDueDay = ''; newDueTime = ''; }}>Cancel</button>
</div> </div>
</form> </form>
{/if} {/if}
@@ -166,7 +188,16 @@
{#if editingTaskId === task.id} {#if editingTaskId === task.id}
<form class="edit-form" onsubmit={saveEdit}> <form class="edit-form" onsubmit={saveEdit}>
<input type="text" bind:value={editTitle} class="task-input" /> <input type="text" bind:value={editTitle} class="task-input" />
<input type="datetime-local" bind:value={editDueDate} class="task-date-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"> <div class="form-actions">
<button type="submit" class="btn-primary btn-sm">Save</button> <button type="submit" class="btn-primary btn-sm">Save</button>
<button type="button" class="btn-secondary btn-sm" onclick={() => editingTaskId = null}>Cancel</button> <button type="button" class="btn-secondary btn-sm" onclick={() => editingTaskId = null}>Cancel</button>
@@ -274,6 +305,29 @@
border-color: var(--color-accent); 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 { .task-date-input {
padding: 8px 12px; padding: 8px 12px;
border-radius: 8px; border-radius: 8px;
@@ -283,9 +337,40 @@
font-size: 13px; font-size: 13px;
outline: none; outline: none;
color-scheme: dark; color-scheme: dark;
flex: 1;
cursor: pointer;
position: relative;
} }
.task-date-input:focus { .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); border-color: var(--color-accent);
} }

View File

@@ -59,9 +59,13 @@
<div class="user-bar"> <div class="user-bar">
<div class="user-chips"> <div class="user-chips">
{#each users as user} {#each users as user}
<button <!-- svelte-ignore node_invalid_placement_ssr -->
<div
class="user-chip {selectedUserId === user.id ? 'selected' : ''}" class="user-chip {selectedUserId === user.id ? 'selected' : ''}"
onclick={() => selectedUserId = user.id} onclick={() => selectedUserId = user.id}
role="button"
tabindex="0"
onkeydown={(e) => { if (e.key === 'Enter') selectedUserId = user.id; }}
> >
<span class="user-name">{user.name}</span> <span class="user-name">{user.name}</span>
{#if confirmDeleteId === user.id} {#if confirmDeleteId === user.id}
@@ -76,11 +80,12 @@
title="Remove user" title="Remove user"
></button> ></button>
{/if} {/if}
</button> </div>
{/each} {/each}
{#if showAddForm} {#if showAddForm}
<form class="add-user-form" onsubmit={(e) => { e.preventDefault(); handleAddUser(); }}> <form class="add-user-form" onsubmit={(e) => { e.preventDefault(); handleAddUser(); }}>
<!-- svelte-ignore a11y_autofocus -->
<input <input
type="text" type="text"
bind:value={newUserName} bind:value={newUserName}

View File

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

View File

@@ -12,7 +12,6 @@
#include "nvs_flash.h" #include "nvs_flash.h"
#include "soc/gpio_num.h" #include "soc/gpio_num.h"
// Project headers // Project headers
#include "appstate.hpp" #include "appstate.hpp"
#include "types.hpp" #include "types.hpp"
@@ -45,25 +44,23 @@ extern "C" void app_main()
nvs_handle_t my_handle; nvs_handle_t my_handle;
if (nvs_open("storage", NVS_READWRITE, &my_handle) == ESP_OK) if (nvs_open("storage", NVS_READWRITE, &my_handle) == ESP_OK)
{ {
// If we are running from the factory partition, force the www partition to // Read active www partition from NVS
// 0 This ensures that after a USB flash (which only writes to www_0), we err = nvs_get_u8(my_handle, "www_part", &g_Active_WWW_Partition);
// aren't stuck looking at an old www_1.
const esp_partition_t *running = esp_ota_get_running_partition(); if (err == ESP_ERR_NVS_NOT_FOUND)
if (running && running->subtype == ESP_PARTITION_SUBTYPE_APP_FACTORY)
{ {
printf( // First boot (no NVS key yet): default to www_0
"Running from factory: resetting www_part to 0 for consistency.\n"); // 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; g_Active_WWW_Partition = 0;
nvs_set_u8(my_handle, "www_part", 0); nvs_set_u8(my_handle, "www_part", 0);
nvs_commit(my_handle); nvs_commit(my_handle);
} }
else else if (err != ESP_OK)
{ {
err = nvs_get_u8(my_handle, "www_part", &g_Active_WWW_Partition); printf("Error reading www_part from NVS: %s\n", esp_err_to_name(err));
if (err != ESP_OK && err != ESP_ERR_NVS_NOT_FOUND) g_Active_WWW_Partition = 0;
{
printf("Error reading www_part from NVS: %s\n", esp_err_to_name(err));
}
} }
if (g_Active_WWW_Partition > 1) if (g_Active_WWW_Partition > 1)

View File

@@ -156,7 +156,38 @@ frontend/src/
└── app.css # +sidebar theme tokens └── app.css # +sidebar theme tokens
``` ```
## 5. Summary ## 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. 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.