Updated the task view and backend to handle more like a routine

This commit is contained in:
2026-03-08 22:10:46 -04:00
parent 4161ff9513
commit 9d3a277f45
14 changed files with 405 additions and 147 deletions

View File

@@ -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}

View File

@@ -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);

View File

@@ -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) {