feat: Implement core frontend application with task management, user selection, and a collapsible sidebar.
This commit is contained in:
@@ -144,7 +144,7 @@
|
||||
<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 -->
|
||||
@@ -175,6 +175,39 @@
|
||||
|
||||
{#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">
|
||||
|
||||
@@ -285,38 +318,6 @@
|
||||
|
||||
</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'}
|
||||
<!-- Task Manager View -->
|
||||
<div class="bg-bg-card border border-border rounded-xl p-6 shadow-xl">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script>
|
||||
let { currentView = 'dashboard', onNavigate = () => {} } = $props();
|
||||
let collapsed = $state(false);
|
||||
let collapsed = $state(typeof window !== 'undefined' && window.innerWidth <= 768);
|
||||
|
||||
const navItems = [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: '🏠' },
|
||||
|
||||
@@ -8,13 +8,15 @@
|
||||
|
||||
// Add task form state
|
||||
let newTitle = $state('');
|
||||
let newDueDate = $state('');
|
||||
let newDueDay = $state('');
|
||||
let newDueTime = $state('');
|
||||
let showAddForm = $state(false);
|
||||
|
||||
// Edit state
|
||||
let editingTaskId = $state(null);
|
||||
let editTitle = $state('');
|
||||
let editDueDate = $state('');
|
||||
let editDueDay = $state('');
|
||||
let editDueTime = $state('');
|
||||
|
||||
// Confirm delete
|
||||
let confirmDeleteId = $state(null);
|
||||
@@ -45,13 +47,14 @@
|
||||
|
||||
async function handleAddTask(e) {
|
||||
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 {
|
||||
await addTask(selectedUserId, newTitle.trim(), dueTimestamp);
|
||||
newTitle = '';
|
||||
newDueDate = '';
|
||||
newDueDay = '';
|
||||
newDueTime = '';
|
||||
showAddForm = false;
|
||||
await fetchTasks();
|
||||
} catch (e) {
|
||||
@@ -71,14 +74,16 @@
|
||||
function startEditing(task) {
|
||||
editingTaskId = task.id;
|
||||
editTitle = task.title;
|
||||
editDueDate = formatDateForInput(task.due_date);
|
||||
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(editDueDate).getTime() / 1000);
|
||||
const dueTimestamp = Math.floor(new Date(`${editDueDay}T${editDueTime}`).getTime() / 1000);
|
||||
try {
|
||||
await updateTask(editingTaskId, {
|
||||
title: editTitle.trim(),
|
||||
@@ -103,7 +108,18 @@
|
||||
|
||||
function formatDateForInput(timestamp) {
|
||||
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) {
|
||||
@@ -135,12 +151,13 @@
|
||||
<div class="task-header">
|
||||
<h2 class="task-title">Tasks</h2>
|
||||
{#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}
|
||||
</div>
|
||||
|
||||
{#if showAddForm}
|
||||
<form class="add-task-form" onsubmit={handleAddTask}>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newTitle}
|
||||
@@ -148,14 +165,19 @@
|
||||
class="task-input"
|
||||
autofocus
|
||||
/>
|
||||
<input
|
||||
type="datetime-local"
|
||||
bind:value={newDueDate}
|
||||
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={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() || !newDueDate}>Add</button>
|
||||
<button type="button" class="btn-secondary" onclick={() => { showAddForm = false; newTitle = ''; newDueDate = ''; }}>Cancel</button>
|
||||
<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}
|
||||
@@ -166,7 +188,16 @@
|
||||
{#if editingTaskId === task.id}
|
||||
<form class="edit-form" onsubmit={saveEdit}>
|
||||
<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">
|
||||
<button type="submit" class="btn-primary btn-sm">Save</button>
|
||||
<button type="button" class="btn-secondary btn-sm" onclick={() => editingTaskId = null}>Cancel</button>
|
||||
@@ -274,6 +305,29 @@
|
||||
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;
|
||||
@@ -283,9 +337,40 @@
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -59,9 +59,13 @@
|
||||
<div class="user-bar">
|
||||
<div class="user-chips">
|
||||
{#each users as user}
|
||||
<button
|
||||
<!-- 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}
|
||||
@@ -76,11 +80,12 @@
|
||||
title="Remove user"
|
||||
>✕</button>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if showAddForm}
|
||||
<form class="add-user-form" onsubmit={(e) => { e.preventDefault(); handleAddUser(); }}>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newUserName}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"major": 0,
|
||||
"minor": 1,
|
||||
"revision": 14
|
||||
"revision": 4
|
||||
}
|
||||
@@ -12,7 +12,6 @@
|
||||
#include "nvs_flash.h"
|
||||
#include "soc/gpio_num.h"
|
||||
|
||||
|
||||
// Project headers
|
||||
#include "appstate.hpp"
|
||||
#include "types.hpp"
|
||||
@@ -45,25 +44,23 @@ extern "C" void app_main()
|
||||
nvs_handle_t my_handle;
|
||||
if (nvs_open("storage", NVS_READWRITE, &my_handle) == ESP_OK)
|
||||
{
|
||||
// If we are running from the factory partition, force the www partition to
|
||||
// 0 This ensures that after a USB flash (which only writes to www_0), we
|
||||
// aren't stuck looking at an old www_1.
|
||||
const esp_partition_t *running = esp_ota_get_running_partition();
|
||||
if (running && running->subtype == ESP_PARTITION_SUBTYPE_APP_FACTORY)
|
||||
// Read active www partition from NVS
|
||||
err = nvs_get_u8(my_handle, "www_part", &g_Active_WWW_Partition);
|
||||
|
||||
if (err == ESP_ERR_NVS_NOT_FOUND)
|
||||
{
|
||||
printf(
|
||||
"Running from factory: resetting www_part to 0 for consistency.\n");
|
||||
// First boot (no NVS key yet): default to www_0
|
||||
// This ensures that after a fresh USB flash (which only writes www_0),
|
||||
// we start from the correct partition.
|
||||
printf("No www_part in NVS, defaulting to 0.\n");
|
||||
g_Active_WWW_Partition = 0;
|
||||
nvs_set_u8(my_handle, "www_part", 0);
|
||||
nvs_commit(my_handle);
|
||||
}
|
||||
else
|
||||
else if (err != ESP_OK)
|
||||
{
|
||||
err = nvs_get_u8(my_handle, "www_part", &g_Active_WWW_Partition);
|
||||
if (err != ESP_OK && err != ESP_ERR_NVS_NOT_FOUND)
|
||||
{
|
||||
printf("Error reading www_part from NVS: %s\n", esp_err_to_name(err));
|
||||
}
|
||||
printf("Error reading www_part from NVS: %s\n", esp_err_to_name(err));
|
||||
g_Active_WWW_Partition = 0;
|
||||
}
|
||||
|
||||
if (g_Active_WWW_Partition > 1)
|
||||
|
||||
@@ -156,7 +156,38 @@ frontend/src/
|
||||
└── 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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user