Asked claude opus to make an audit of code and use coding guidelines etc. This is the fix

This commit is contained in:
2026-03-09 22:26:16 -04:00
parent 75d88f441c
commit b702839f8e
15 changed files with 459 additions and 1291 deletions

View File

@@ -1,5 +1,6 @@
<script>
import { getSystemInfo, reboot, getOTAStatus, getUpcomingTasks } from "./lib/api.js";
import { formatUptime, formatBytes, formatRelativeDate, isOverdue } from "./lib/utils.js";
import OTAUpdate from "./lib/OTAUpdate.svelte";
import Sidebar from "./lib/Sidebar.svelte";
import TaskManager from "./lib/TaskManager.svelte";
@@ -32,49 +33,7 @@
let upcomingData = $state({ users: [] });
/** Format uptime seconds into human-readable string */
function formatUptime(seconds) {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
const parts = [];
if (d > 0) parts.push(`${d}d`);
if (h > 0) parts.push(`${h}h`);
if (m > 0) parts.push(`${m}m`);
parts.push(`${s}s`);
return parts.join(" ");
}
/** Format bytes into human-readable string */
function formatBytes(bytes) {
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
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;
}
let isFetching = false;
let isFetching = false; // mutex, not reactive
let lastKnownFirmware = null;
let lastKnownSlot = null;
async function fetchAll(silent = false) {
@@ -158,7 +117,7 @@
]);
</script>
<div class="app-layout">
<div class="flex min-h-screen bg-bg-primary">
<Sidebar
{currentView}
isOpen={mobileMenuOpen}
@@ -168,25 +127,25 @@
{#if mobileMenuOpen}
<button
class="mobile-backdrop"
class="fixed inset-0 bg-black/40 backdrop-blur z-[999] border-none p-0 cursor-pointer"
onclick={() => mobileMenuOpen = false}
aria-label="Close menu"
></button>
{/if}
<main class="main-content">
<main class="main-gradient-bg flex-1 p-8 h-screen overflow-y-auto max-md:p-4 max-md:h-auto">
<!-- Mobile Top Bar -->
<header class="mobile-header">
<header class="hidden max-md:flex items-center justify-between px-4 py-3 bg-bg-card border-b border-border sticky top-0 z-50 backdrop-blur-[10px] max-md:-mx-4 max-md:-mt-4 max-md:mb-5">
<!-- svelte-ignore a11y_consider_explicit_label -->
<button class="hamburger" onclick={() => mobileMenuOpen = true}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<button class="bg-transparent border-none text-text-primary p-2 cursor-pointer" onclick={() => mobileMenuOpen = true}>
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
</button>
<span class="mobile-title">Calendink</span>
<div style="width: 40px;"></div> <!-- Spacer -->
<span class="font-bold text-base tracking-[-0.02em] text-accent">Calendink</span>
<div class="w-10"></div> <!-- Spacer -->
</header>
<div class="w-full max-w-6xl mx-auto space-y-8">
@@ -378,7 +337,7 @@
</div>
<!-- Updates & Maintenance Card -->
<OTAUpdate onReboot={() => { status = "rebooting"; isRecovering = true; }} />
<OTAUpdate otaInfo={otaStatus} {systemInfo} onReboot={() => { status = "rebooting"; isRecovering = true; }} />
</div>
</div>
@@ -426,87 +385,3 @@
</div>
<Spinner />
<style>
.app-layout {
display: flex;
min-height: 100vh;
background: var(--color-bg-primary);
}
.main-content {
flex: 1;
padding: 32px;
height: 100vh;
overflow-y: auto;
background: radial-gradient(circle at top right, var(--color-bg-card), transparent 40%),
var(--color-bg-primary);
}
.mobile-header {
display: none;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--color-bg-card);
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 0;
z-index: 50;
backdrop-filter: blur(10px);
}
.hamburger {
background: none;
border: none;
color: var(--color-text-primary);
padding: 8px;
cursor: pointer;
}
.hamburger svg {
width: 24px;
height: 24px;
}
.mobile-title {
font-weight: 700;
font-size: 16px;
letter-spacing: -0.02em;
color: var(--color-accent);
}
.mobile-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
z-index: 999;
border: none;
padding: 0;
cursor: pointer;
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@media (max-width: 768px) {
.main-content {
padding: 16px;
height: auto;
}
.mobile-header {
display: flex;
margin: -16px -16px 20px -16px;
}
.mobile-backdrop {
display: block;
}
}
</style>

View File

@@ -17,3 +17,28 @@
--color-danger: #ef4444;
--color-danger-hover: #f87171;
}
/*
* Global utility: main content radial gradient background.
* Can't be expressed as a Tailwind arbitrary value due to multiple background layers.
*/
.main-gradient-bg {
background: radial-gradient(circle at top right, var(--color-bg-card), transparent 40%),
var(--color-bg-primary);
}
/*
* Global utility: makes the native date picker icon cover the full input area.
* This is a vendor pseudo-element — cannot be done with Tailwind utilities.
*/
.date-input::-webkit-calendar-picker-indicator {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: auto;
height: auto;
opacity: 0;
cursor: pointer;
}

View File

@@ -1,47 +1,26 @@
<script>
let { onReboot = null } = $props();
import { getOTAStatus, uploadOTAFrontend, uploadOTAFirmware, uploadOTABundle, getSystemInfo } from "./api.js";
import { uploadOTAFrontend, uploadOTAFirmware, uploadOTABundle } from "./api.js";
// otaInfo and systemInfo are passed from App.svelte (already fetched there every 5s)
let {
onReboot = null,
otaInfo = { active_slot: -1, active_partition: "—", target_partition: "—", partitions: [], running_firmware_label: "—" },
systemInfo = { firmware: "—" }
} = $props();
const IS_DEV = import.meta.env.DEV;
/** @type {'idle' | 'loading_status' | 'uploading' | 'success' | 'error'} */
/** @type {'idle' | 'uploading' | 'success' | 'error'} */
let status = $state("idle");
let errorMsg = $state("");
let uploadProgress = $state(0);
let otaInfo = $state({
active_slot: -1,
active_partition: "—",
target_partition: "—",
partitions: [],
running_firmware_label: "—"
});
let systemInfo = $state({
firmware: "—"
});
let selectedFile = $state(null);
let showAdvanced = $state(false);
/** @type {'frontend' | 'firmware' | 'bundle'} */
let updateMode = $state("frontend");
let isDragging = $state(false);
async function fetchStatus() {
status = "loading_status";
try {
[otaInfo, systemInfo] = await Promise.all([getOTAStatus(), getSystemInfo()]);
status = "idle";
} catch (e) {
status = "error";
errorMsg = "Failed to fetch OTA status: " + e.message;
}
}
$effect(() => {
fetchStatus();
});
function handleFileChange(event) {
const files = event.target.files;
if (files && files.length > 0) processFile(files[0]);
@@ -114,7 +93,7 @@
}
}
const currentTarget = $derived(() => {
const currentTarget = $derived.by(() => {
if (updateMode === 'bundle') return 'FW + UI';
if (updateMode === 'frontend') return otaInfo.target_partition;
// For firmware, target is the slot that is NOT the running one
@@ -182,7 +161,7 @@
OTA Upgrade ({updateMode === 'frontend' ? 'UI' : 'Firmware'})
</h3>
<div class="text-[10px] text-text-secondary">
Target: <span class="font-mono text-accent">{currentTarget()}</span>
Target: <span class="font-mono text-accent">{currentTarget}</span>
{#if updateMode === 'frontend' && otaInfo.partitions}
<span class="ml-1">
({(otaInfo.partitions.find(p => p.label === otaInfo.target_partition)?.size / 1024 / 1024).toFixed(1)} MB)

View File

@@ -16,17 +16,29 @@
];
</script>
<aside class="sidebar {collapsed ? 'collapsed' : ''} {isOpen ? 'mobile-open' : ''}">
<div class="sidebar-header">
<aside class="
flex flex-col bg-bg-card border-r border-border h-screen flex-shrink-0 sticky top-0
transition-all duration-300
{collapsed ? 'w-16' : 'w-[200px]'}
max-md:fixed max-md:left-0 max-md:top-0 max-md:z-[1000] max-md:!w-[280px]
max-md:shadow-[20px_0_50px_rgba(0,0,0,0.3)]
{isOpen ? 'max-md:flex' : 'max-md:hidden'}
">
<div class="flex items-center border-b border-border min-h-14 px-3 py-4
{collapsed ? 'justify-center' : 'justify-between'}
max-md:px-4 max-md:py-5">
{#if !collapsed}
<span class="sidebar-title">Menu</span>
<span class="text-xs font-bold uppercase tracking-[0.05em] text-text-secondary whitespace-nowrap overflow-hidden">
Menu
</span>
{/if}
<button
class="collapse-btn {onToggle ? 'mobile-only' : ''}"
class="bg-transparent border-none text-text-secondary cursor-pointer p-1.5 flex items-center justify-center rounded-md transition-all flex-shrink-0 hover:text-text-primary hover:bg-bg-card-hover
{onToggle ? 'max-md:flex' : ''}"
onclick={() => onToggle ? onToggle() : (collapsed = !collapsed)}
title={collapsed ? 'Expand' : 'Collapse'}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<svg class="w-[18px] h-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
@@ -34,160 +46,22 @@
</button>
</div>
<nav class="sidebar-nav">
<nav class="flex flex-col gap-1 p-2">
{#each navItems as item}
<button
class="nav-item {currentView === item.id ? 'active' : ''}"
class="flex items-center gap-2.5 px-3 py-2.5 border-none rounded-lg cursor-pointer text-[13px] font-medium transition-all whitespace-nowrap text-left
max-md:px-4 max-md:py-3.5 max-md:text-[15px]
{currentView === item.id
? 'bg-accent/15 text-accent'
: 'bg-transparent text-text-secondary hover:bg-bg-card-hover hover:text-text-primary'}"
onclick={() => onNavigate(item.id)}
title={item.label}
>
<span class="nav-icon">{item.icon}</span>
<span class="text-base flex-shrink-0">{item.icon}</span>
{#if !collapsed}
<span class="nav-label">{item.label}</span>
<span class="overflow-hidden">{item.label}</span>
{/if}
</button>
{/each}
</nav>
</aside>
<style>
.sidebar {
width: 200px;
height: 100vh;
background: var(--color-bg-card);
border-right: 1px solid var(--color-border);
display: flex;
flex-direction: column;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
flex-shrink: 0;
position: sticky;
top: 0;
}
.sidebar.collapsed {
width: 64px;
}
.sidebar.collapsed .sidebar-header {
justify-content: center;
}
.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: 6px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
transition: all 0.2s;
flex-shrink: 0;
}
.collapse-btn svg {
width: 18px;
height: 18px;
}
.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;
}
@media (max-width: 768px) {
.sidebar {
display: none; /* Hide by default on mobile */
position: fixed;
left: 0;
top: 0;
z-index: 1000;
width: 280px !important;
height: 100vh;
transform: translateX(-100%);
box-shadow: 20px 0 50px rgba(0,0,0,0.3);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.sidebar.mobile-open {
display: flex;
transform: translateX(0);
}
.sidebar-header {
padding: 20px 16px;
}
.nav-item {
padding: 14px 16px;
font-size: 15px;
}
.mobile-only {
display: flex !important;
}
}
</style>

View File

@@ -1,21 +1,17 @@
<script>
import { pendingRequests } from './stores.js';
import { onMount, onDestroy } from 'svelte';
let showSpinner = false;
let timer;
let showSpinner = $state(false);
let timer = null; // script-only handle, not reactive
// Subscribe to the store
const unsubscribe = pendingRequests.subscribe(count => {
// Track pending request count and show spinner after 1s delay
$effect(() => {
const count = $pendingRequests;
if (count > 0) {
// Only show the spinner if the request takes longer than 1000ms
if (!timer) {
timer = setTimeout(() => {
showSpinner = true;
}, 1000);
timer = setTimeout(() => { showSpinner = true; }, 1000);
}
} else {
// Instantly hide the spinner when all requests finish
if (timer) {
clearTimeout(timer);
timer = null;
@@ -23,27 +19,13 @@
showSpinner = false;
}
});
onDestroy(() => {
unsubscribe();
if (timer) clearTimeout(timer);
});
</script>
{#if showSpinner}
<div class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/40 backdrop-blur-sm transition-opacity duration-300">
<div class="flex flex-col items-center p-8 bg-surface-base rounded-2xl shadow-xl border border-divider">
<!-- Loading circle animation -->
<div class="w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin"></div>
<div class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div class="flex flex-col items-center p-8 bg-bg-card rounded-2xl shadow-xl border border-border">
<div class="w-12 h-12 border-4 border-accent border-t-transparent rounded-full animate-spin"></div>
<p class="mt-4 text-text-primary font-medium tracking-wide animate-pulse">Communicating with Device...</p>
</div>
</div>
{/if}
<style>
/*
* Note: 'bg-surface-base', 'border-divider', 'text-text-primary'
* are assumed to be part of the app's global tailwind theme.
* Adjust classes if necessary.
*/
</style>

View File

@@ -1,5 +1,6 @@
<script>
import { getTasks, addTask, updateTask, deleteTask } from './api.js';
import { formatRelativeDate, isOverdue } from './utils.js';
import UserManager from './UserManager.svelte';
let selectedUserId = $state(null);
@@ -97,7 +98,6 @@
async function handleAddTask(e) {
e.preventDefault();
if (!newTitle.trim()) return;
// For non-recurrent tasks, require a date
if (newRecurrence === 0 && !newDueDay) return;
const dueTimestamp = newRecurrence > 0
@@ -183,59 +183,48 @@
newDueDay = `${year}-${month}-${dayNum}`;
}
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(task) {
if (task.recurrence > 0) return false; // Recurrent tasks don't have overdue
return task.due_date < Date.now() / 1000;
function taskIsOverdue(task) {
if (task.recurrence > 0) return false;
return isOverdue(task.due_date);
}
</script>
<div class="task-manager">
<div class="w-full">
<UserManager bind:selectedUserId onUsersChanged={fetchTasks} />
{#if selectedUserId}
<div class="task-header">
<h2 class="task-title">Tasks</h2>
<div class="flex items-center justify-between mb-3">
<h2 class="text-sm font-semibold uppercase tracking-[0.05em] text-text-primary">Tasks</h2>
{#if !showAddForm}
<button class="add-task-btn" onclick={() => { showAddForm = true; setTodayForNewTask(); }}>+ Add Task</button>
<button
class="px-3.5 py-1.5 rounded-lg border border-accent bg-accent/10 text-accent cursor-pointer text-xs font-semibold transition-all hover:bg-accent/20"
onclick={() => { showAddForm = true; setTodayForNewTask(); }}
>+ Add Task</button>
{/if}
</div>
{#if showAddForm}
<form class="add-task-form" onsubmit={handleAddTask}>
<form class="flex flex-col gap-2 p-3 rounded-[10px] border border-border bg-bg-card mb-3" onsubmit={handleAddTask}>
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
bind:value={newTitle}
placeholder="Task title..."
class="task-input"
class="px-3 py-2 rounded-lg border border-border bg-bg-primary text-text-primary text-[13px] outline-none w-full box-border focus:border-accent"
autofocus
/>
<div class="field-row">
<div class="field-group">
<span class="field-label">Period</span>
<div class="period-selector">
<div class="flex gap-2">
<div class="flex flex-col gap-1 flex-1">
<span class="text-[10px] font-semibold uppercase tracking-[0.05em] text-text-secondary">Period</span>
<div class="flex gap-1">
{#each PERIODS as p, i}
<button
type="button"
class="period-btn {hasBit(newPeriod, i) ? 'active' : ''}"
class="flex-1 px-2 py-1.5 rounded-md border cursor-pointer text-[11px] transition-all whitespace-nowrap
{hasBit(newPeriod, i)
? 'border-accent bg-accent/15 text-accent font-semibold'
: 'border-border bg-bg-primary text-text-secondary hover:bg-bg-card-hover'}"
onclick={() => newPeriod = toggleBit(newPeriod, i)}
>{PERIOD_ICONS[i]} {p}</button>
{/each}
@@ -243,14 +232,17 @@
</div>
</div>
<div class="field-row">
<div class="field-group">
<span class="field-label">Recurrence</span>
<div class="recurrence-selector">
<div class="flex gap-2">
<div class="flex flex-col gap-1 flex-1">
<span class="text-[10px] font-semibold uppercase tracking-[0.05em] text-text-secondary">Recurrence</span>
<div class="flex gap-[3px] flex-wrap">
{#each DAYS as day, i}
<button
type="button"
class="rec-btn {hasBit(newRecurrence, i) ? 'active' : ''}"
class="px-2 py-[5px] rounded-md border cursor-pointer text-[11px] transition-all min-w-8 text-center
{hasBit(newRecurrence, i)
? 'border-accent bg-accent/15 text-accent font-semibold'
: 'border-border bg-bg-primary text-text-secondary hover:bg-bg-card-hover'}"
onclick={() => newRecurrence = toggleBit(newRecurrence, i)}
>{day}</button>
{/each}
@@ -259,36 +251,46 @@
</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" />
<div class="flex gap-2">
<label class="flex flex-col gap-1 flex-1">
<span class="text-[10px] font-semibold uppercase tracking-[0.05em] text-text-secondary">Date</span>
<input type="date" bind:value={newDueDay}
class="date-input px-3 py-2 rounded-lg border border-border bg-bg-primary text-text-primary text-[13px] outline-none [color-scheme:dark] flex-1 cursor-pointer relative focus:border-accent" />
</label>
</div>
{/if}
<div class="form-actions">
<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 class="flex gap-1.5">
<button type="submit"
class="px-4 py-1.5 rounded-lg border-none bg-accent text-white cursor-pointer text-xs font-semibold transition-[filter] hover:brightness-110 disabled:opacity-40 disabled:cursor-not-allowed"
disabled={!newTitle.trim() || (newRecurrence === 0 && !newDueDay)}>Add</button>
<button type="button"
class="px-4 py-1.5 rounded-lg border border-border bg-transparent text-text-secondary cursor-pointer text-xs transition-all hover:bg-bg-card-hover"
onclick={() => { showAddForm = false; newTitle = ''; newDueDay = ''; newRecurrence = 0; newPeriod = 0x01; }}>Cancel</button>
</div>
</form>
{/if}
<div class="task-list">
<div class="flex flex-col gap-1">
{#each tasks as task}
<div class="task-item {task.completed ? 'completed' : ''} {isOverdue(task) && !task.completed ? 'overdue' : ''}">
<div class="flex items-center justify-between flex-wrap px-3.5 py-2.5 rounded-[10px] border bg-bg-card transition-all hover:bg-bg-card-hover
{task.completed ? 'opacity-50' : ''}
{taskIsOverdue(task) && !task.completed ? 'border-danger/40' : 'border-border'}">
{#if editingTaskId === task.id}
<form class="edit-form" onsubmit={saveEdit}>
<input type="text" bind:value={editTitle} class="task-input" />
<form class="w-full flex flex-col gap-2 p-0" onsubmit={saveEdit}>
<input type="text" bind:value={editTitle}
class="px-3 py-2 rounded-lg border border-border bg-bg-primary text-text-primary text-[13px] outline-none w-full box-border focus:border-accent" />
<div class="field-row">
<div class="field-group">
<span class="field-label">Period</span>
<div class="period-selector">
<div class="flex gap-2">
<div class="flex flex-col gap-1 flex-1">
<span class="text-[10px] font-semibold uppercase tracking-[0.05em] text-text-secondary">Period</span>
<div class="flex gap-1">
{#each PERIODS as p, i}
<button
type="button"
class="period-btn {hasBit(editPeriod, i) ? 'active' : ''}"
<button type="button"
class="flex-1 px-2 py-1.5 rounded-md border cursor-pointer text-[11px] transition-all whitespace-nowrap
{hasBit(editPeriod, i)
? 'border-accent bg-accent/15 text-accent font-semibold'
: 'border-border bg-bg-primary text-text-secondary hover:bg-bg-card-hover'}"
onclick={() => editPeriod = toggleBit(editPeriod, i)}
>{PERIOD_ICONS[i]} {p}</button>
{/each}
@@ -296,14 +298,16 @@
</div>
</div>
<div class="field-row">
<div class="field-group">
<span class="field-label">Recurrence</span>
<div class="recurrence-selector">
<div class="flex gap-2">
<div class="flex flex-col gap-1 flex-1">
<span class="text-[10px] font-semibold uppercase tracking-[0.05em] text-text-secondary">Recurrence</span>
<div class="flex gap-[3px] flex-wrap">
{#each DAYS as day, i}
<button
type="button"
class="rec-btn {hasBit(editRecurrence, i) ? 'active' : ''}"
<button type="button"
class="px-2 py-[5px] rounded-md border cursor-pointer text-[11px] transition-all min-w-8 text-center
{hasBit(editRecurrence, i)
? 'border-accent bg-accent/15 text-accent font-semibold'
: 'border-border bg-bg-primary text-text-secondary hover:bg-bg-card-hover'}"
onclick={() => editRecurrence = toggleBit(editRecurrence, i)}
>{day}</button>
{/each}
@@ -312,425 +316,71 @@
</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" />
<div class="flex gap-2">
<label class="flex flex-col gap-1 flex-1">
<span class="text-[10px] font-semibold uppercase tracking-[0.05em] text-text-secondary">Date</span>
<input type="date" bind:value={editDueDay}
class="date-input px-3 py-2 rounded-lg border border-border bg-bg-primary text-text-primary text-[13px] outline-none [color-scheme:dark] flex-1 cursor-pointer relative focus:border-accent" />
</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>
<div class="flex gap-1.5">
<button type="submit"
class="px-2.5 py-1 rounded-lg border-none bg-accent text-white cursor-pointer text-[11px] font-semibold transition-[filter] hover:brightness-110">Save</button>
<button type="button"
class="px-2.5 py-1 rounded-lg border border-border bg-transparent text-text-secondary cursor-pointer text-[11px] transition-all hover:bg-bg-card-hover"
onclick={() => editingTaskId = null}>Cancel</button>
</div>
</form>
{:else}
<div class="task-left">
<div class="flex items-center gap-2.5 flex-1 min-w-0">
<input
type="checkbox"
checked={task.completed}
onchange={() => handleToggleComplete(task)}
class="task-checkbox"
class="w-4 h-4 cursor-pointer flex-shrink-0"
style="accent-color: var(--color-accent)"
/>
<div class="task-info">
<span class="task-text">{task.title}</span>
<div class="task-meta">
<span class="task-period">{periodIcons(task.period)} {formatPeriod(task.period)}</span>
<div class="flex flex-col gap-0.5 min-w-0">
<span class="text-[13px] text-text-primary whitespace-nowrap overflow-hidden text-ellipsis
{task.completed ? 'line-through text-text-secondary' : ''}">{task.title}</span>
<div class="flex gap-2 items-center flex-wrap">
<span class="text-[10px] text-text-secondary font-medium">{periodIcons(task.period)} {formatPeriod(task.period)}</span>
{#if task.recurrence > 0}
<span class="task-recurrence">🔁 {formatRecurrence(task.recurrence)}</span>
<span class="text-[10px] text-accent font-medium">🔁 {formatRecurrence(task.recurrence)}</span>
{:else}
<span class="task-due {isOverdue(task) && !task.completed ? 'text-overdue' : ''}">
<span class="text-[10px] font-medium {taskIsOverdue(task) && !task.completed ? 'text-danger' : 'text-text-secondary'}">
{formatRelativeDate(task.due_date)}
</span>
{/if}
</div>
</div>
</div>
<div class="task-actions">
<button class="action-btn" onclick={() => startEditing(task)} title="Edit">✏️</button>
<div class="flex gap-1 items-center flex-shrink-0">
<button class="bg-transparent border-none cursor-pointer text-xs p-1 rounded opacity-60 transition-[background] hover:opacity-100 hover:bg-bg-card-hover"
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>
<button class="bg-transparent border-none cursor-pointer text-xs p-1 rounded text-danger opacity-60 hover:opacity-100"
onclick={() => handleDelete(task.id)} title="Confirm"></button>
<button class="bg-transparent border-none cursor-pointer text-xs p-1 rounded opacity-60 hover:opacity-100 hover:bg-bg-card-hover"
onclick={() => confirmDeleteId = null} title="Cancel"></button>
{:else}
<button class="action-btn" onclick={() => confirmDeleteId = task.id} title="Delete">🗑️</button>
<button class="bg-transparent border-none cursor-pointer text-xs p-1 rounded opacity-60 transition-[background] hover:opacity-100 hover:bg-bg-card-hover"
onclick={() => confirmDeleteId = task.id} title="Delete">🗑️</button>
{/if}
</div>
{/if}
</div>
{:else}
<div class="empty-state">No tasks yet. Add one above!</div>
<div class="text-center p-8 text-text-secondary text-[13px]">No tasks yet. Add one above!</div>
{/each}
</div>
{:else}
<div class="empty-state">Select or add a user to see their tasks.</div>
<div class="text-center p-8 text-text-secondary text-[13px]">Select or add a user to see their tasks.</div>
{/if}
{#if error}
<div class="task-error">{error}</div>
<div class="mt-2 px-2.5 py-1.5 rounded-md bg-danger/10 border border-danger/20 text-danger text-[11px]">{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);
}
.field-row {
display: flex;
gap: 8px;
}
.field-group {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.field-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
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;
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;
}
/* Make the native picker icon cover the entire input (Chrome/Edge only) */
.task-date-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 {
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;
flex-wrap: wrap;
padding: 10px 14px;
border-radius: 10px;
border: 1px solid var(--color-border);
background: var(--color-bg-card);
transition: all 0.15s;
}
.task-item .edit-form {
width: 100%;
margin: 0;
}
.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-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);
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>

View File

@@ -75,362 +75,134 @@
}
}
// Load initial data on mount
$effect(() => {
fetchUsers();
});
</script>
</script>
<div class="user-manager mode-{mode}">
<div class="mb-4">
{#if mode === 'selector'}
<div class="user-bar">
<div class="user-chips">
<div class="flex items-center gap-2">
<div class="flex flex-wrap gap-1.5 items-center">
{#each users as user}
<!-- svelte-ignore node_invalid_placement_ssr -->
<div
class="user-chip {selectedUserId === user.id ? 'selected' : ''}"
class="flex items-center gap-1.5 px-4 py-1.5 rounded-full border cursor-pointer text-[13px] font-semibold
transition-all duration-200 hover:-translate-y-px hover:shadow-[0_4px_12px_rgba(0,0,0,0.1)]
{selectedUserId === user.id
? 'bg-accent/20 border-accent text-accent ring-1 ring-accent'
: 'border-border bg-bg-card text-text-secondary hover:border-accent hover:text-text-primary'}"
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>{user.name}</span>
</div>
{/each}
</div>
</div>
{:else}
<!-- Manager Mode -->
<header class="manager-header">
<header class="flex justify-between items-center mb-6">
<h1 class="text-xl font-bold text-text-primary">User Management</h1>
<button class="add-user-btn-large" onclick={() => showAddForm = true}>
<button
class="px-5 py-2.5 rounded-xl bg-accent text-white border-none font-semibold cursor-pointer transition-all
shadow-accent/30 shadow-lg hover:brightness-110 hover:-translate-y-px"
onclick={() => showAddForm = true}
>
+ New User
</button>
</header>
{#if showAddForm}
<div class="modal-overlay">
<form class="add-user-modal" onsubmit={(e) => { e.preventDefault(); handleAddUser(); }}>
<h3 class="text-lg font-semibold">Add User</h3>
<div class="fixed inset-0 bg-black/70 backdrop-blur flex items-center justify-center z-[100]">
<form
class="bg-bg-card p-6 rounded-[20px] border border-border w-full max-w-sm flex flex-col gap-5 shadow-[0_20px_40px_rgba(0,0,0,0.4)]"
onsubmit={(e) => { e.preventDefault(); handleAddUser(); }}
>
<h3 class="text-lg font-semibold text-text-primary">Add User</h3>
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
bind:value={newUserName}
placeholder="Name..."
class="modal-input"
class="px-4 py-3 rounded-xl border border-border bg-bg-primary text-text-primary text-sm outline-none focus:border-accent"
autofocus
/>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick={() => { showAddForm = false; newUserName = ''; }}>Cancel</button>
<button type="submit" class="btn-primary" disabled={!newUserName.trim()}>Add User</button>
<div class="flex gap-3 justify-end">
<button
type="button"
class="px-4 py-2 rounded-lg bg-bg-primary text-text-secondary border border-border cursor-pointer transition-all hover:bg-bg-card-hover"
onclick={() => { showAddForm = false; newUserName = ''; }}
>Cancel</button>
<button
type="submit"
class="px-4 py-2 rounded-lg bg-accent text-white border-none font-semibold cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!newUserName.trim()}
>Add User</button>
</div>
</form>
</div>
{/if}
<div class="user-list">
<div class="grid gap-3">
{#each users as user}
<div class="user-item">
<div class="flex justify-between items-center px-5 py-4 rounded-2xl bg-bg-card border border-border transition-all hover:border-accent hover:bg-bg-card-hover">
{#if editingUserId === user.id}
<form class="edit-row" onsubmit={(e) => { e.preventDefault(); handleUpdateUser(); }}>
<form class="flex gap-3 w-full" onsubmit={(e) => { e.preventDefault(); handleUpdateUser(); }}>
<!-- svelte-ignore a11y_autofocus -->
<input type="text" bind:value={editName} class="edit-input" autofocus />
<div class="edit-actions">
<button type="submit" class="save-btn" title="Save"></button>
<button type="button" class="cancel-btn" onclick={() => editingUserId = null} title="Cancel"></button>
<input
type="text"
bind:value={editName}
class="flex-1 px-3 py-2 rounded-lg border border-accent bg-bg-primary text-text-primary outline-none"
autofocus
/>
<div class="flex gap-1.5">
<button type="submit" class="px-3 py-1 rounded-md border-none bg-success text-white cursor-pointer transition-all" title="Save"></button>
<button type="button" class="px-3 py-1 rounded-md border border-border bg-bg-primary text-text-secondary cursor-pointer transition-all" onclick={() => editingUserId = null} title="Cancel"></button>
</div>
</form>
{:else}
<div class="user-info">
<span class="user-name-large">{user.name}</span>
<span class="user-id">ID: {user.id}</span>
<div class="flex flex-col gap-1">
<span class="text-base font-semibold text-text-primary">{user.name}</span>
<span class="text-[11px] text-text-secondary font-mono uppercase tracking-[0.05em]">ID: {user.id}</span>
</div>
<div class="user-actions">
<button class="action-btn" onclick={() => startEditing(user)} title="Rename">✏️</button>
<div class="flex gap-2">
<button
class="bg-bg-primary border border-border text-text-secondary p-2 rounded-lg cursor-pointer text-sm transition-all hover:border-accent hover:text-accent hover:bg-bg-card"
onclick={() => startEditing(user)}
title="Rename"
>✏️</button>
{#if confirmDeleteId === user.id}
<button class="action-btn danger" onclick={() => handleRemoveUser(user.id)}>Delete!</button>
<button class="action-btn" onclick={() => confirmDeleteId = null}>Cancel</button>
<button
class="bg-danger/10 border border-danger/30 text-danger px-3 py-1 rounded-lg cursor-pointer text-sm transition-all"
onclick={() => handleRemoveUser(user.id)}
>Delete!</button>
<button
class="bg-bg-primary border border-border text-text-secondary p-2 rounded-lg cursor-pointer text-sm transition-all hover:bg-bg-card-hover"
onclick={() => confirmDeleteId = null}
>Cancel</button>
{:else}
<button class="action-btn" onclick={() => confirmDeleteId = user.id} title="Delete">🗑️</button>
<button
class="bg-bg-primary border border-border text-text-secondary p-2 rounded-lg cursor-pointer text-sm transition-all hover:border-danger hover:text-danger hover:bg-danger/10"
onclick={() => confirmDeleteId = user.id}
title="Delete"
>🗑️</button>
{/if}
</div>
{/if}
</div>
{:else}
<div class="empty-state">No users found. Create one to get started!</div>
<div class="text-center px-12 py-12 bg-bg-card border border-dashed border-border rounded-2xl text-text-secondary">
No users found. Create one to get started!
</div>
{/each}
</div>
{/if}
{#if error}
<div class="user-error">{error}</div>
<div class="mt-4 p-3 rounded-xl bg-danger/10 border border-danger/20 text-danger text-[13px] text-center">{error}</div>
{/if}
</div>
<style>
.user-manager {
margin-bottom: 16px;
}
/* Selector Mode Styles */
.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 16px;
border-radius: 20px;
border: 1px solid var(--color-border);
background: var(--color-bg-card);
color: var(--color-text-secondary);
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.user-chip:hover {
border-color: var(--color-accent);
color: var(--color-text-primary);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.user-chip.selected {
background: color-mix(in srgb, var(--color-accent) 20%, transparent);
border-color: var(--color-accent);
color: var(--color-accent);
box-shadow: 0 0 0 1px var(--color-accent);
}
/* Manager Mode Styles */
.manager-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.add-user-btn-large {
padding: 10px 20px;
border-radius: 12px;
background: var(--color-accent);
color: white;
border: none;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 4px 14px color-mix(in srgb, var(--color-accent) 40%, transparent);
}
.add-user-btn-large:hover {
filter: brightness(1.1);
transform: translateY(-1px);
}
.user-list {
display: grid;
gap: 12px;
}
.user-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-radius: 16px;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
transition: all 0.2s;
}
.user-item:hover {
border-color: var(--color-accent);
background: var(--color-bg-card-hover);
}
.user-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.user-name-large {
font-size: 16px;
font-weight: 600;
color: var(--color-text-primary);
}
.user-id {
font-size: 11px;
color: var(--color-text-secondary);
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.user-actions {
display: flex;
gap: 8px;
}
.action-btn {
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
padding: 8px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.action-btn:hover {
border-color: var(--color-accent);
color: var(--color-accent);
background: var(--color-bg-card);
}
.action-btn.danger:hover {
border-color: var(--color-danger);
color: var(--color-danger);
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
}
/* Modal Styles */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.add-user-modal {
background: var(--color-bg-card);
padding: 24px;
border-radius: 20px;
border: 1px solid var(--color-border);
width: 100%;
max-width: 400px;
display: flex;
flex-direction: column;
gap: 20px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
}
.modal-input {
padding: 12px 16px;
border-radius: 12px;
border: 1px solid var(--color-border);
background: var(--color-bg-primary);
color: var(--color-text-primary);
font-size: 14px;
}
.modal-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
}
/* Edit Inline Row */
.edit-row {
display: flex;
gap: 12px;
width: 100%;
}
.edit-input {
flex: 1;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--color-accent);
background: var(--color-bg-primary);
color: var(--color-text-primary);
outline: none;
}
.edit-actions {
display: flex;
gap: 6px;
}
.save-btn, .cancel-btn {
padding: 4px 12px;
border-radius: 6px;
border: 1px solid var(--color-border);
cursor: pointer;
transition: all 0.2s;
}
.save-btn {
background: var(--color-success);
color: white;
border-color: var(--color-success);
}
.cancel-btn {
background: var(--color-bg-primary);
color: var(--color-text-secondary);
}
/* Common UI items */
.btn-primary {
padding: 8px 16px;
border-radius: 8px;
background: var(--color-accent);
color: white;
border: none;
font-weight: 600;
cursor: pointer;
}
.btn-secondary {
padding: 8px 16px;
border-radius: 8px;
background: var(--color-bg-primary);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
cursor: pointer;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.user-error {
margin-top: 16px;
padding: 12px;
border-radius: 12px;
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: 13px;
text-align: center;
}
.empty-state {
text-align: center;
padding: 48px;
background: var(--color-bg-card);
border: 1px dashed var(--color-border);
border-radius: 16px;
color: var(--color-text-secondary);
}
</style>
</div>

View File

@@ -0,0 +1,48 @@
/**
* Shared utility functions for the Calendink dashboard.
* Import from here — never duplicate these across components.
*/
/** Format uptime seconds into human-readable string */
export function formatUptime(seconds) {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
const parts = [];
if (d > 0) parts.push(`${d}d`);
if (h > 0) parts.push(`${h}h`);
if (m > 0) parts.push(`${m}m`);
parts.push(`${s}s`);
return parts.join(' ');
}
/** Format bytes into human-readable string */
export function formatBytes(bytes) {
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${bytes} B`;
}
/** Format a Unix timestamp (seconds) as a human-readable relative string */
export 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`;
}
/** Returns true if a Unix timestamp (seconds) is in the past */
export function isOverdue(timestamp) {
return timestamp < Date.now() / 1000;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -298,9 +298,11 @@ internal httpd_handle_t start_webserver(void)
httpd_register_uri_handler(server, &api_tasks_update_uri);
httpd_register_uri_handler(server, &api_tasks_delete_uri);
// Populate dummy data for development
// Populate dummy data for development (debug builds only)
#ifndef NDEBUG
seed_users();
seed_tasks();
#endif
#ifdef CONFIG_CALENDINK_DEPLOY_WEB_PAGES
// Register static file handler last as a catch-all wildcard if deployed

View File

@@ -26,6 +26,8 @@
#include "mdns_service.cpp"
// clang-format on
internal const char *kTagMain = "MAIN";
// Global Application State Definitions
bool g_Ethernet_Initialized = false;
bool g_Wifi_Initialized = false;
@@ -35,9 +37,8 @@ constexpr bool kBlockUntilEthernetEstablished = false;
extern "C" void app_main()
{
printf("Hello, Calendink OTA! [V1.1]\n");
printf("PSRAM size: %d bytes\n", esp_psram_get_size());
ESP_LOGI(kTagMain, "Hello, Calendink OTA! [V0.1.1]");
ESP_LOGI(kTagMain, "PSRAM size: %d bytes", esp_psram_get_size());
httpd_handle_t web_server = NULL;
@@ -56,22 +57,22 @@ extern "C" void app_main()
err = nvs_get_u8(my_handle, "www_part", &g_Active_WWW_Partition);
if (err == ESP_OK)
{
printf("NVS: Found active www partition: %d\n", g_Active_WWW_Partition);
ESP_LOGI(kTagMain, "NVS: Found active www partition: %d",
g_Active_WWW_Partition);
}
if (err == ESP_ERR_NVS_NOT_FOUND)
{
// 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");
ESP_LOGI(kTagMain, "No www_part in NVS, defaulting to 0.");
g_Active_WWW_Partition = 0;
nvs_set_u8(my_handle, "www_part", 0);
nvs_commit(my_handle);
}
else if (err != ESP_OK)
{
printf("Error reading www_part from NVS: %s\n", esp_err_to_name(err));
ESP_LOGE(kTagMain, "Error reading www_part from NVS: %s",
esp_err_to_name(err));
g_Active_WWW_Partition = 0;
}
@@ -83,7 +84,7 @@ extern "C" void app_main()
}
else
{
printf("Error opening NVS handle!\n");
ESP_LOGE(kTagMain, "Error opening NVS handle!");
}
// Detect if this is the first boot after a new flash (Firmware or Frontend)
@@ -98,7 +99,7 @@ extern "C" void app_main()
ESP_OK ||
strcmp(last_time, current_time) != 0)
{
printf("New firmware detected! (Last: %s, Current: %s)\n",
ESP_LOGI(kTagMain, "New firmware detected! (Last: %s, Current: %s)",
last_time[0] ? last_time : "None", current_time);
is_new_flash = true;
nvs_set_str(my_handle, "last_fw_time", current_time);
@@ -118,7 +119,7 @@ extern "C" void app_main()
ESP_OK ||
memcmp(last_sha, current_sha, 32) != 0)
{
printf("New frontend partition detected via SHA256!\n");
ESP_LOGI(kTagMain, "New frontend partition detected via SHA256!");
is_new_flash = true;
nvs_set_blob(my_handle, "www0_sha", current_sha, 32);
}
@@ -141,7 +142,8 @@ extern "C" void app_main()
{
if (is_new_flash && g_Active_WWW_Partition != 0)
{
printf("FACTORY APP + NEW FLASH: Overriding www_part to 0 (was %d)\n",
ESP_LOGW(kTagMain,
"FACTORY APP + NEW FLASH: Overriding www_part to 0 (was %d)",
g_Active_WWW_Partition);
g_Active_WWW_Partition = 0;
@@ -181,7 +183,7 @@ extern "C" void app_main()
if (result == ESP_ERR_INVALID_STATE)
{
printf("Ethernet cable not plugged in, skipping retries.\n");
ESP_LOGW(kTagMain, "Ethernet cable not plugged in, skipping retries.");
break;
}
@@ -198,7 +200,7 @@ extern "C" void app_main()
if (result != ESP_OK)
{
printf("Ethernet failed, trying wifi\n");
ESP_LOGW(kTagMain, "Ethernet failed, trying wifi");
disconnect_ethernet();
g_Ethernet_Initialized = false;
@@ -235,20 +237,20 @@ extern "C" void app_main()
if (result != ESP_OK)
{
printf("Wifi failed.\n");
ESP_LOGE(kTagMain, "Wifi failed.");
goto shutdown;
}
set_led_status(led_status::ReadyWifi);
printf("Will use Wifi!\n");
ESP_LOGI(kTagMain, "Will use Wifi!");
}
else
{
set_led_status(led_status::ReadyEthernet);
printf("Will use Ethernet!\n");
ESP_LOGI(kTagMain, "Will use Ethernet!");
}
printf("Connected! IP acquired.\n");
ESP_LOGI(kTagMain, "Connected! IP acquired.");
// Start the webserver
web_server = start_webserver();
@@ -274,7 +276,7 @@ extern "C" void app_main()
}
shutdown:
printf("Shutting down.\n");
ESP_LOGE(kTagMain, "Shutting down.");
if (web_server)
{

View File

@@ -2,7 +2,9 @@
#include "mdns.h"
#include "sdkconfig.h"
static const char *kLogMDNS = "MDNS";
#include "types.hpp"
internal const char *kTagMDNS = "MDNS";
void start_mdns()
{
@@ -10,14 +12,14 @@ void start_mdns()
esp_err_t err = mdns_init();
if (err != ESP_OK)
{
ESP_LOGE(kLogMDNS, "mDNS Init failed: %d", err);
ESP_LOGE(kTagMDNS, "mDNS Init failed: %d", err);
return;
}
// Set mDNS hostname (from Kconfig)
const char *hostname = CONFIG_CALENDINK_MDNS_HOSTNAME;
mdns_hostname_set(hostname);
ESP_LOGI(kLogMDNS, "mDNS Hostname set to: [%s.local]", hostname);
ESP_LOGI(kTagMDNS, "mDNS Hostname set to: [%s.local]", hostname);
// Set mDNS instance name
mdns_instance_name_set("Calendink Provider");
@@ -26,9 +28,8 @@ void start_mdns()
err = mdns_service_add(NULL, "_http", "_tcp", 80, NULL, 0);
if (err != ESP_OK)
{
ESP_LOGE(kLogMDNS, "mDNS Service add failed: %d", err);
ESP_LOGE(kTagMDNS, "mDNS Service add failed: %d", err);
}
printf("MDNS: Service initialized with hostname [%s.local]\n", hostname);
ESP_LOGI(kLogMDNS, "mDNS Service initialized");
ESP_LOGI(kTagMDNS, "mDNS Service initialized with hostname [%s.local]", hostname);
}