Updated the task view and backend to handle more like a routine
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -93,3 +93,6 @@ external/*
|
||||
|
||||
# Agent Tasks
|
||||
Provider/AgentTasks/
|
||||
|
||||
# OTA files
|
||||
*.bundle
|
||||
@@ -1,2 +1,2 @@
|
||||
VITE_API_BASE=http://192.168.50.216
|
||||
VITE_API_BASE=http://calendink.local/
|
||||
MKLITTLEFS_PATH=W:\Classified\Calendink\Provider\build\littlefs_py_venv\Scripts\littlefs-python.exe
|
||||
|
||||
@@ -32,7 +32,7 @@ function loadEnv() {
|
||||
|
||||
loadEnv();
|
||||
|
||||
const targetIP = process.env.VITE_API_BASE ? process.env.VITE_API_BASE.replace('http://', '') : null;
|
||||
const targetIP = process.env.VITE_API_BASE ? process.env.VITE_API_BASE.replace('http://', '').replace(/\/+$/, '') : null;
|
||||
|
||||
if (!targetIP) {
|
||||
console.error('Error: VITE_API_BASE not found in frontend/.env. Please set it to the target device IP.');
|
||||
|
||||
@@ -75,6 +75,8 @@
|
||||
}
|
||||
|
||||
let isFetching = false;
|
||||
let lastKnownFirmware = null;
|
||||
let lastKnownSlot = null;
|
||||
async function fetchAll(silent = false) {
|
||||
if (isFetching) return;
|
||||
isFetching = true;
|
||||
@@ -85,6 +87,16 @@
|
||||
getOTAStatus(),
|
||||
getUpcomingTasks().catch(() => ({ users: [] }))
|
||||
]);
|
||||
// Detect any OTA update: firmware version change OR www partition flip
|
||||
const fwChanged = lastKnownFirmware && sys.firmware !== lastKnownFirmware;
|
||||
const slotChanged = lastKnownSlot !== null && ota.active_slot !== lastKnownSlot;
|
||||
if (fwChanged || slotChanged) {
|
||||
console.log(`OTA detected (fw: ${fwChanged}, slot: ${slotChanged}). Reloading...`);
|
||||
window.location.href = window.location.pathname + '?t=' + Date.now();
|
||||
return;
|
||||
}
|
||||
lastKnownFirmware = sys.firmware;
|
||||
lastKnownSlot = ota.active_slot;
|
||||
systemInfo = sys;
|
||||
otaStatus = ota;
|
||||
upcomingData = upcoming;
|
||||
@@ -180,7 +192,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 -->
|
||||
@@ -214,29 +226,46 @@
|
||||
|
||||
<!-- Upcoming Tasks Section (top priority) -->
|
||||
{#if upcomingData.users.length > 0}
|
||||
{@const periodNames = ['Morning', 'Afternoon', 'Evening']}
|
||||
{@const periodIcons = ['🌅', '☀️', '🌙']}
|
||||
<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
|
||||
📋 Today's Routine
|
||||
</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}
|
||||
{@const routineTasks = user.tasks.filter(t => t.recurrence > 0)}
|
||||
<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>
|
||||
{#if routineTasks.length === 0}
|
||||
<p class="text-[11px] text-text-secondary italic">No routine 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>
|
||||
{#each [0, 1, 2] as periodIdx}
|
||||
{@const tasksForPeriod = routineTasks.filter(t => t.period & (1 << periodIdx))}
|
||||
{#if tasksForPeriod.length > 0}
|
||||
<div class="mb-2">
|
||||
<div class="text-[10px] uppercase tracking-wider text-text-secondary font-semibold mb-1">
|
||||
{periodIcons[periodIdx]} {periodNames[periodIdx]}
|
||||
</div>
|
||||
{#each tasksForPeriod as task}
|
||||
<div class="flex items-center gap-2 py-0.5 pl-3">
|
||||
<span class="text-xs text-text-primary leading-tight">• {task.title}</span>
|
||||
{#if task.recurrence > 0}
|
||||
<span class="text-[9px] text-accent font-mono">
|
||||
{task.recurrence === 0x7F ? '∞' : task.recurrence === 0x1F ? 'wk' : ''}
|
||||
</span>
|
||||
{:else if task.due_date > 0}
|
||||
<span class="text-[9px] {isOverdue(task.due_date) ? 'text-danger' : 'text-text-secondary'} font-mono">
|
||||
{formatRelativeDate(task.due_date)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -6,17 +6,61 @@
|
||||
let tasks = $state([]);
|
||||
let error = $state('');
|
||||
|
||||
// Period and day-of-week labels
|
||||
const PERIODS = ['Morning', 'Afternoon', 'Evening'];
|
||||
const PERIOD_ICONS = ['🌅', '☀️', '🌙'];
|
||||
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
|
||||
// Shared bitmask helpers (used for both period and recurrence)
|
||||
function toggleBit(current, idx) {
|
||||
return current ^ (1 << idx);
|
||||
}
|
||||
|
||||
function hasBit(mask, idx) {
|
||||
return (mask & (1 << idx)) !== 0;
|
||||
}
|
||||
|
||||
function formatPeriod(mask) {
|
||||
if (mask === 0x07) return 'All Day';
|
||||
const names = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (mask & (1 << i)) names.push(PERIODS[i]);
|
||||
}
|
||||
return names.length > 0 ? names.join(', ') : 'None';
|
||||
}
|
||||
|
||||
function periodIcons(mask) {
|
||||
const icons = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (mask & (1 << i)) icons.push(PERIOD_ICONS[i]);
|
||||
}
|
||||
return icons.join('');
|
||||
}
|
||||
|
||||
function formatRecurrence(mask) {
|
||||
if (mask === 0x7F) return 'Every day';
|
||||
if (mask === 0x1F) return 'Weekdays';
|
||||
if (mask === 0x60) return 'Weekends';
|
||||
const names = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
if (mask & (1 << i)) names.push(DAYS[i]);
|
||||
}
|
||||
return names.join(', ');
|
||||
}
|
||||
|
||||
// Add task form state
|
||||
let newTitle = $state('');
|
||||
let newPeriod = $state(0x01);
|
||||
let newRecurrence = $state(0);
|
||||
let newDueDay = $state('');
|
||||
let newDueTime = $state('');
|
||||
let showAddForm = $state(false);
|
||||
|
||||
// Edit state
|
||||
let editingTaskId = $state(null);
|
||||
let editTitle = $state('');
|
||||
let editPeriod = $state(0);
|
||||
let editRecurrence = $state(0);
|
||||
let editDueDay = $state('');
|
||||
let editDueTime = $state('');
|
||||
|
||||
// Confirm delete
|
||||
let confirmDeleteId = $state(null);
|
||||
@@ -28,8 +72,13 @@
|
||||
}
|
||||
try {
|
||||
tasks = await getTasks(selectedUserId);
|
||||
// Sort by due_date ascending
|
||||
tasks.sort((a, b) => a.due_date - b.due_date);
|
||||
// Sort: recurrent first (by day), then one-off by due_date
|
||||
tasks.sort((a, b) => {
|
||||
if (a.recurrence && !b.recurrence) return -1;
|
||||
if (!a.recurrence && b.recurrence) return 1;
|
||||
if (a.recurrence && b.recurrence) return a.recurrence - b.recurrence;
|
||||
return a.due_date - b.due_date;
|
||||
});
|
||||
error = '';
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
@@ -47,14 +96,20 @@
|
||||
|
||||
async function handleAddTask(e) {
|
||||
e.preventDefault();
|
||||
if (!newTitle.trim() || !newDueDay || !newDueTime) return;
|
||||
if (!newTitle.trim()) return;
|
||||
// For non-recurrent tasks, require a date
|
||||
if (newRecurrence === 0 && !newDueDay) return;
|
||||
|
||||
const dueTimestamp = newRecurrence > 0
|
||||
? 0
|
||||
: Math.floor(new Date(`${newDueDay}T00:00`).getTime() / 1000);
|
||||
|
||||
const dueTimestamp = Math.floor(new Date(`${newDueDay}T${newDueTime}`).getTime() / 1000);
|
||||
try {
|
||||
await addTask(selectedUserId, newTitle.trim(), dueTimestamp);
|
||||
await addTask(selectedUserId, newTitle.trim(), dueTimestamp, newPeriod, newRecurrence);
|
||||
newTitle = '';
|
||||
newPeriod = 0x01;
|
||||
newRecurrence = 0;
|
||||
newDueDay = '';
|
||||
newDueTime = '';
|
||||
showAddForm = false;
|
||||
await fetchTasks();
|
||||
} catch (e) {
|
||||
@@ -74,20 +129,34 @@
|
||||
function startEditing(task) {
|
||||
editingTaskId = task.id;
|
||||
editTitle = task.title;
|
||||
const parts = formatDateForInput(task.due_date);
|
||||
editDueDay = parts.day;
|
||||
editDueTime = parts.time;
|
||||
editPeriod = task.period;
|
||||
editRecurrence = task.recurrence;
|
||||
if (task.recurrence === 0 && task.due_date) {
|
||||
const d = new Date(task.due_date * 1000);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dayNum = String(d.getDate()).padStart(2, '0');
|
||||
editDueDay = `${year}-${month}-${dayNum}`;
|
||||
} else {
|
||||
editDueDay = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEdit(e) {
|
||||
e.preventDefault();
|
||||
if (!editTitle.trim()) return;
|
||||
if (editRecurrence === 0 && !editDueDay) return;
|
||||
|
||||
const dueTimestamp = editRecurrence > 0
|
||||
? 0
|
||||
: Math.floor(new Date(`${editDueDay}T00:00`).getTime() / 1000);
|
||||
|
||||
const dueTimestamp = Math.floor(new Date(`${editDueDay}T${editDueTime}`).getTime() / 1000);
|
||||
try {
|
||||
await updateTask(editingTaskId, {
|
||||
title: editTitle.trim(),
|
||||
due_date: dueTimestamp
|
||||
due_date: dueTimestamp,
|
||||
period: editPeriod,
|
||||
recurrence: editRecurrence
|
||||
});
|
||||
editingTaskId = null;
|
||||
await fetchTasks();
|
||||
@@ -106,20 +175,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateForInput(timestamp) {
|
||||
const d = new Date(timestamp * 1000);
|
||||
function setTodayForNewTask() {
|
||||
const d = new Date();
|
||||
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;
|
||||
newDueDay = `${year}-${month}-${dayNum}`;
|
||||
}
|
||||
|
||||
function formatRelativeDate(timestamp) {
|
||||
@@ -139,8 +200,9 @@
|
||||
return diff < 0 ? `${days}d ago` : `in ${days}d`;
|
||||
}
|
||||
|
||||
function isOverdue(timestamp) {
|
||||
return timestamp < Date.now() / 1000;
|
||||
function isOverdue(task) {
|
||||
if (task.recurrence > 0) return false; // Recurrent tasks don't have overdue
|
||||
return task.due_date < Date.now() / 1000;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -151,7 +213,7 @@
|
||||
<div class="task-header">
|
||||
<h2 class="task-title">Tasks</h2>
|
||||
{#if !showAddForm}
|
||||
<button class="add-task-btn" onclick={() => { showAddForm = true; setNowForNewTask(); }}>+ Add Task</button>
|
||||
<button class="add-task-btn" onclick={() => { showAddForm = true; setTodayForNewTask(); }}>+ Add Task</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -165,39 +227,99 @@
|
||||
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 class="field-row">
|
||||
<div class="field-group">
|
||||
<span class="field-label">Period</span>
|
||||
<div class="period-selector">
|
||||
{#each PERIODS as p, i}
|
||||
<button
|
||||
type="button"
|
||||
class="period-btn {hasBit(newPeriod, i) ? 'active' : ''}"
|
||||
onclick={() => newPeriod = toggleBit(newPeriod, i)}
|
||||
>{PERIOD_ICONS[i]} {p}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field-group">
|
||||
<span class="field-label">Recurrence</span>
|
||||
<div class="recurrence-selector">
|
||||
{#each DAYS as day, i}
|
||||
<button
|
||||
type="button"
|
||||
class="rec-btn {hasBit(newRecurrence, i) ? 'active' : ''}"
|
||||
onclick={() => newRecurrence = toggleBit(newRecurrence, i)}
|
||||
>{day}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if newRecurrence === 0}
|
||||
<div class="field-row">
|
||||
<label class="field-group">
|
||||
<span class="field-label">Date</span>
|
||||
<input type="date" bind:value={newDueDay} class="task-date-input" />
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<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>
|
||||
<button type="submit" class="btn-primary" disabled={!newTitle.trim() || (newRecurrence === 0 && !newDueDay)}>Add</button>
|
||||
<button type="button" class="btn-secondary" onclick={() => { showAddForm = false; newTitle = ''; newDueDay = ''; newRecurrence = 0; newPeriod = 0x01; }}>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' : ''}">
|
||||
<div class="task-item {task.completed ? 'completed' : ''} {isOverdue(task) && !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 class="field-row">
|
||||
<div class="field-group">
|
||||
<span class="field-label">Period</span>
|
||||
<div class="period-selector">
|
||||
{#each PERIODS as p, i}
|
||||
<button
|
||||
type="button"
|
||||
class="period-btn {hasBit(editPeriod, i) ? 'active' : ''}"
|
||||
onclick={() => editPeriod = toggleBit(editPeriod, i)}
|
||||
>{PERIOD_ICONS[i]} {p}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field-group">
|
||||
<span class="field-label">Recurrence</span>
|
||||
<div class="recurrence-selector">
|
||||
{#each DAYS as day, i}
|
||||
<button
|
||||
type="button"
|
||||
class="rec-btn {hasBit(editRecurrence, i) ? 'active' : ''}"
|
||||
onclick={() => editRecurrence = toggleBit(editRecurrence, i)}
|
||||
>{day}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if editRecurrence === 0}
|
||||
<div class="field-row">
|
||||
<label class="field-group">
|
||||
<span class="field-label">Date</span>
|
||||
<input type="date" bind:value={editDueDay} class="task-date-input" />
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<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>
|
||||
@@ -213,9 +335,16 @@
|
||||
/>
|
||||
<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 class="task-meta">
|
||||
<span class="task-period">{periodIcons(task.period)} {formatPeriod(task.period)}</span>
|
||||
{#if task.recurrence > 0}
|
||||
<span class="task-recurrence">🔁 {formatRecurrence(task.recurrence)}</span>
|
||||
{:else}
|
||||
<span class="task-due {isOverdue(task) && !task.completed ? 'text-overdue' : ''}">
|
||||
{formatRelativeDate(task.due_date)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-actions">
|
||||
@@ -305,18 +434,15 @@
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.date-time-row {
|
||||
.field-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.date-time-field {
|
||||
.field-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.date-time-field:first-child {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -328,6 +454,65 @@
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.period-selector {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.period-btn {
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-primary);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.period-btn:hover {
|
||||
background: var(--color-bg-card-hover);
|
||||
}
|
||||
|
||||
.period-btn.active {
|
||||
border-color: var(--color-accent);
|
||||
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.recurrence-selector {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rec-btn {
|
||||
padding: 5px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-primary);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
transition: all 0.15s;
|
||||
min-width: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rec-btn:hover {
|
||||
background: var(--color-bg-card-hover);
|
||||
}
|
||||
|
||||
.rec-btn.active {
|
||||
border-color: var(--color-accent);
|
||||
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.task-date-input {
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
@@ -342,23 +527,8 @@
|
||||
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 {
|
||||
.task-date-input::-webkit-calendar-picker-indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -370,7 +540,7 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.task-date-input:focus, .task-time-input:focus {
|
||||
.task-date-input:focus {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
@@ -430,6 +600,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -437,6 +608,11 @@
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.task-item .edit-form {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.task-item:hover {
|
||||
background: var(--color-bg-card-hover);
|
||||
}
|
||||
@@ -485,6 +661,25 @@
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.task-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.task-period {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.task-recurrence {
|
||||
font-size: 10px;
|
||||
color: var(--color-accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.task-due {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
@@ -222,14 +222,16 @@ export async function getUpcomingTasks() {
|
||||
* 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}>}
|
||||
* @param {number} dueDate Unix timestamp in seconds (used for non-recurrent tasks)
|
||||
* @param {number} period 0=Morning, 1=Afternoon, 2=Evening
|
||||
* @param {number} recurrence 0=None, 1-7=Day of week (1=Mon)
|
||||
* @returns {Promise<{id: number, user_id: number, title: string, due_date: number, period: number, recurrence: number, completed: boolean}>}
|
||||
*/
|
||||
export async function addTask(userId, title, dueDate) {
|
||||
export async function addTask(userId, title, dueDate, period = 0, recurrence = 0) {
|
||||
const res = await trackedFetch(`${API_BASE}/api/tasks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: userId, title, due_date: dueDate })
|
||||
body: JSON.stringify({ user_id: userId, title, due_date: dueDate, period, recurrence })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
@@ -241,7 +243,7 @@ export async function addTask(userId, title, dueDate) {
|
||||
/**
|
||||
* 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 }
|
||||
* @param {Object} fields - { title?: string, due_date?: number, period?: number, recurrence?: number, completed?: boolean }
|
||||
* @returns {Promise<{status: string}>}
|
||||
*/
|
||||
export async function updateTask(id, fields) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"major": 0,
|
||||
"minor": 1,
|
||||
"revision": 23
|
||||
"revision": 30
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// POST /api/tasks — Create a new task
|
||||
// Body: {"user_id":1, "title":"...", "due_date":1741369200}
|
||||
// Body: {"user_id":1, "title":"...", "due_date":1741369200, "period":0,
|
||||
// "recurrence":0}
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "esp_http_server.h"
|
||||
@@ -31,19 +32,26 @@ internal esp_err_t api_tasks_post_handler(httpd_req_t *req)
|
||||
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");
|
||||
cJSON *period_item = cJSON_GetObjectItem(body, "period");
|
||||
cJSON *recurrence_item = cJSON_GetObjectItem(body, "recurrence");
|
||||
|
||||
if (!cJSON_IsNumber(user_id_item) || !cJSON_IsString(title_item) ||
|
||||
!cJSON_IsNumber(due_date_item))
|
||||
if (!cJSON_IsNumber(user_id_item) || !cJSON_IsString(title_item))
|
||||
{
|
||||
cJSON_Delete(body);
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST,
|
||||
"Missing user_id, title, or due_date");
|
||||
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing user_id or title");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
int64 due_date =
|
||||
cJSON_IsNumber(due_date_item) ? (int64)due_date_item->valuedouble : 0;
|
||||
uint8 period = cJSON_IsNumber(period_item) ? (uint8)period_item->valueint
|
||||
: (uint8)PERIOD_MORNING;
|
||||
uint8 recurrence =
|
||||
cJSON_IsNumber(recurrence_item) ? (uint8)recurrence_item->valueint : 0;
|
||||
|
||||
task_t *task =
|
||||
add_task((uint8)user_id_item->valueint, title_item->valuestring,
|
||||
(int64)due_date_item->valuedouble);
|
||||
add_task((uint8)user_id_item->valueint, title_item->valuestring, due_date,
|
||||
period, recurrence);
|
||||
cJSON_Delete(body);
|
||||
|
||||
if (!task)
|
||||
@@ -58,6 +66,8 @@ internal esp_err_t api_tasks_post_handler(httpd_req_t *req)
|
||||
cJSON_AddNumberToObject(resp, "user_id", task->user_id);
|
||||
cJSON_AddStringToObject(resp, "title", task->title);
|
||||
cJSON_AddNumberToObject(resp, "due_date", (double)task->due_date);
|
||||
cJSON_AddNumberToObject(resp, "period", task->period);
|
||||
cJSON_AddNumberToObject(resp, "recurrence", task->recurrence);
|
||||
cJSON_AddBoolToObject(resp, "completed", task->completed);
|
||||
|
||||
const char *json = cJSON_PrintUnformatted(resp);
|
||||
|
||||
@@ -41,6 +41,8 @@ internal esp_err_t api_tasks_get_handler(httpd_req_t *req)
|
||||
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_AddNumberToObject(obj, "period", g_Tasks[i].period);
|
||||
cJSON_AddNumberToObject(obj, "recurrence", g_Tasks[i].recurrence);
|
||||
cJSON_AddBoolToObject(obj, "completed", g_Tasks[i].completed);
|
||||
cJSON_AddItemToArray(arr, obj);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@ task_t *find_task(uint16 id)
|
||||
}
|
||||
|
||||
// Add a task, returns pointer to new task or nullptr if full
|
||||
task_t *add_task(uint8 user_id, const char *title, int64 due_date)
|
||||
task_t *add_task(uint8 user_id, const char *title, int64 due_date, uint8 period,
|
||||
uint8 recurrence)
|
||||
{
|
||||
// Verify user exists
|
||||
if (find_user(user_id) == nullptr)
|
||||
@@ -35,6 +36,8 @@ task_t *add_task(uint8 user_id, const char *title, int64 due_date)
|
||||
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].period = period;
|
||||
g_Tasks[i].recurrence = recurrence;
|
||||
g_Tasks[i].completed = false;
|
||||
g_Tasks[i].active = true;
|
||||
return &g_Tasks[i];
|
||||
@@ -94,18 +97,19 @@ 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
|
||||
// Alice's tasks (user_id = 1) — mix of one-off and recurring
|
||||
add_task(1, "Buy groceries", now + 86400, PERIOD_MORNING);
|
||||
add_task(1, "Review PR #42", now + 3600, PERIOD_AFTERNOON);
|
||||
add_task(1, "Book dentist appointment", now + 172800, PERIOD_MORNING);
|
||||
add_task(1, "Update resume", now + 604800, PERIOD_EVENING);
|
||||
|
||||
// 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
|
||||
// Bob's tasks (user_id = 2) — some recurring routines
|
||||
add_task(2, "Morning standup", 0, PERIOD_MORNING, 0x1F); // Mon-Fri
|
||||
add_task(2, "Deploy staging", now + 43200, PERIOD_AFTERNOON);
|
||||
add_task(2, "Write unit tests", now + 259200, PERIOD_MORNING);
|
||||
|
||||
// Charlie's tasks (user_id = 3)
|
||||
add_task(3, "Water plants", now + 1800); // +30 min
|
||||
add_task(3, "Call plumber", now + 86400); // +1 day
|
||||
// Charlie's tasks (user_id = 3) — kid routine examples
|
||||
add_task(3, "Breakfast", 0, PERIOD_MORNING, 0x1F); // Mon-Fri
|
||||
add_task(3, "Homework", 0, PERIOD_AFTERNOON, 0x15); // Mon+Wed+Fri
|
||||
add_task(3, "Bath time", 0, PERIOD_EVENING, 0x7F); // Every day
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
|
||||
// Data store operations for tasks
|
||||
task_t *find_task(uint16 id);
|
||||
task_t *add_task(uint8 user_id, const char *title, int64 due_date);
|
||||
task_t *add_task(uint8 user_id, const char *title, int64 due_date,
|
||||
uint8 period = PERIOD_MORNING, uint8 recurrence = 0);
|
||||
bool remove_task(uint16 id);
|
||||
void remove_tasks_for_user(uint8 user_id);
|
||||
void sort_tasks_by_due_date(task_t **arr, int count);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// GET /api/tasks/upcoming — Top 3 upcoming tasks per user (for Dashboard)
|
||||
// GET /api/tasks/upcoming — Today's tasks per user, grouped by period
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "esp_http_server.h"
|
||||
@@ -20,36 +20,26 @@ internal esp_err_t api_tasks_upcoming_handler(httpd_req_t *req)
|
||||
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
|
||||
// Include: recurring tasks (any day) and one-off tasks
|
||||
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++)
|
||||
|
||||
for (int t = 0; t < MAX_TASKS; t++)
|
||||
{
|
||||
if (!g_Tasks[t].active || g_Tasks[t].completed ||
|
||||
g_Tasks[t].user_id != g_Users[u].id)
|
||||
continue;
|
||||
|
||||
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_AddNumberToObject(t_obj, "id", g_Tasks[t].id);
|
||||
cJSON_AddStringToObject(t_obj, "title", g_Tasks[t].title);
|
||||
cJSON_AddNumberToObject(t_obj, "due_date", (double)g_Tasks[t].due_date);
|
||||
cJSON_AddNumberToObject(t_obj, "period", g_Tasks[t].period);
|
||||
cJSON_AddNumberToObject(t_obj, "recurrence", g_Tasks[t].recurrence);
|
||||
cJSON_AddBoolToObject(t_obj, "completed", g_Tasks[t].completed);
|
||||
cJSON_AddItemToArray(tasks_arr, t_obj);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// POST /api/tasks/update — Modify a task
|
||||
// Body: {"id":1, "title":"...", "due_date":..., "completed":true}
|
||||
// All fields except "id" are optional
|
||||
// Body: {"id":1, "title":"...", "due_date":..., "period":0, "recurrence":0,
|
||||
// "completed":true} All fields except "id" are optional
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "esp_http_server.h"
|
||||
@@ -58,6 +58,18 @@ internal esp_err_t api_tasks_update_handler(httpd_req_t *req)
|
||||
task->due_date = (int64)due_date_item->valuedouble;
|
||||
}
|
||||
|
||||
cJSON *period_item = cJSON_GetObjectItem(body, "period");
|
||||
if (cJSON_IsNumber(period_item))
|
||||
{
|
||||
task->period = (uint8)period_item->valueint;
|
||||
}
|
||||
|
||||
cJSON *recurrence_item = cJSON_GetObjectItem(body, "recurrence");
|
||||
if (cJSON_IsNumber(recurrence_item))
|
||||
{
|
||||
task->recurrence = (uint8)recurrence_item->valueint;
|
||||
}
|
||||
|
||||
cJSON *completed_item = cJSON_GetObjectItem(body, "completed");
|
||||
if (cJSON_IsBool(completed_item))
|
||||
{
|
||||
|
||||
@@ -5,14 +5,24 @@
|
||||
#include "types.hpp"
|
||||
#include "user.hpp"
|
||||
|
||||
enum task_period_t : uint8
|
||||
{
|
||||
PERIOD_MORNING = 0x01, // bit 0
|
||||
PERIOD_AFTERNOON = 0x02, // bit 1
|
||||
PERIOD_EVENING = 0x04, // bit 2
|
||||
PERIOD_ALL_DAY = 0x07 // all bits
|
||||
};
|
||||
|
||||
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
|
||||
char title[64]; // Task description
|
||||
int64 due_date; // Unix timestamp (seconds) - used when recurrence is 0
|
||||
uint16 id; // Auto-assigned (1–65535, 0 = empty slot)
|
||||
uint8 user_id; // Owner (matches user_t.id)
|
||||
uint8 recurrence; // Bitmask: bit0=Mon, bit1=Tue, ..., bit6=Sun. 0=none
|
||||
uint8 period : 3; // Bitmask: bit0=Morning, bit1=Afternoon, bit2=Evening
|
||||
bool completed : 1; // Done flag
|
||||
bool active : 1; // Slot in use
|
||||
};
|
||||
|
||||
constexpr int MAX_TASKS = 32;
|
||||
|
||||
Reference in New Issue
Block a user