const { useState, useEffect, useCallback, useRef } = React; /* ───────────────────────────────────────────────────────── DAYBOARD — three-phase daily task board with Gmail login Employee portal: sign up / log in, edit own tasks only. Admin portal: oversee & edit everyone, remove employees. ───────────────────────────────────────────────────────── */ const MODE = TaskDB.mode; // 'employee' | 'admin' let nonce = TaskDB.nonce; // refreshed after every auth call const PHASES = [ { id: "morning", label: "Morning", short: "AM", color: "#E8553F", desc: "Start of day" }, { id: "midday", label: "Post-lunch", short: "PL", color: "#F2A93B", desc: "After lunch" }, { id: "eod", label: "End of day", short: "EOD", color: "#3DA164", desc: "Day close" }, ]; const PRIORITIES = [ { id: "high", label: "High", color: "#E8553F", bg: "#FDEAE6" }, { id: "medium", label: "Medium", color: "#B47B14", bg: "#FCF1DC" }, { id: "low", label: "Low", color: "#5B6470", bg: "#EEF0F2" }, ]; const prioRank = { high: 0, medium: 1, low: 2 }; /* Assigned-by-admin styling — one place, so the badge, the row tint and the stripe can never drift apart. Deliberately blue: nothing else on the board uses it (green = done, amber = in progress, purple = notes/source). */ const ADMIN_TASK = { color: "#1F5FBF", bg: "#F0F6FF", border: "#BBD2F7", label: "From admin", }; /* Filed through the public request link. Badge only — the tinted row stays unique to admin tasks so that signal doesn't get diluted. */ const EXTERNAL_TASK = { color: "#0F766E", border: "#B6DED8" }; /* offset of the sticky header + control bar, shared by everything that pins */ const STICKY_TOP = "6.4rem"; /* ── date helpers ── */ const toISO = (d) => { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, "0"); const dd = String(d.getDate()).padStart(2, "0"); return `${y}-${m}-${dd}`; }; const todayISO = () => toISO(new Date()); const shiftDate = (iso, days) => { const [y, m, d] = iso.split("-").map(Number); const dt = new Date(y, m - 1, d); dt.setDate(dt.getDate() + days); return toISO(dt); }; const prettyDate = (iso) => { const [y, m, d] = iso.split("-").map(Number); return new Date(y, m - 1, d).toLocaleDateString("en-IN", { weekday: "short", day: "numeric", month: "short", year: "numeric", }); }; const currentPhase = () => { const h = new Date().getHours(); if (h < 13) return "morning"; if (h < 18) return "midday"; return "eod"; }; /* ── week / month helpers ── */ const mondayOf = (iso) => { const [y, m, d] = iso.split("-").map(Number); const dt = new Date(y, m - 1, d); const dow = (dt.getDay() + 6) % 7; // 0 = Monday dt.setDate(dt.getDate() - dow); return toISO(dt); }; const weekLabel = (mondayIso) => { const [y, m, d] = mondayIso.split("-").map(Number); const f = (dt) => dt.toLocaleDateString("en-IN", { day: "numeric", month: "short" }); return `${f(new Date(y, m - 1, d))} – ${f(new Date(y, m - 1, d + 6))}`; }; const thisMonthKey = () => todayISO().slice(0, 7); const monthLabel = (key) => { const [y, m] = key.split("-").map(Number); return new Date(y, m - 1, 1).toLocaleDateString("en-IN", { month: "long", year: "numeric" }); }; const shiftMonth = (key, delta) => { let [y, m] = key.split("-").map(Number); m += delta; while (m < 1) { m += 12; y--; } while (m > 12) { m -= 12; y++; } return `${y}-${String(m).padStart(2, "0")}`; }; /* ── API helper (cookie auth + rolling nonce) ── */ async function api(path, opts = {}) { let res, data = null; try { res = await fetch(TaskDB.rest + path, { ...opts, credentials: "same-origin", headers: { "Content-Type": "application/json", "X-WP-Nonce": nonce, ...(opts.headers || {}) }, }); data = await res.json().catch(() => null); } catch { return { ok: false, status: 0, data: null }; } if (data && data.nonce) nonce = data.nonce; return { ok: res.ok, status: res.status, data }; } const apiError = (r, fallback) => (r.data && (r.data.message || r.data.error)) || fallback; const emptyBoard = () => ({ members: [], tasks: {}, checkins: {} }); const initials = (name) => (name || "?").split(" ").map((w) => w[0]).join("").slice(0, 2).toUpperCase(); let uid = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 6); /* ── calendar helpers ── */ const MONTHS_SHORT = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; const DOW_SHORT = ["M", "T", "W", "T", "F", "S", "S"]; /* Six full Mon-Sun weeks covering the given YYYY-MM, so the grid never jumps height. */ const monthGrid = (key) => { const [y, m] = key.split("-").map(Number); const startDow = (new Date(y, m - 1, 1).getDay() + 6) % 7; // 0 = Monday const cells = []; for (let i = 0; i < 42; i++) { const d = new Date(y, m - 1, 1 - startDow + i); cells.push({ iso: toISO(d), day: d.getDate(), inMonth: d.getMonth() === m - 1 }); } return cells; }; /* close a popover on outside click or Escape */ function useDismiss(ref, onDismiss) { useEffect(() => { const click = (e) => { if (ref.current && !ref.current.contains(e.target)) onDismiss(); }; const key = (e) => { if (e.key === "Escape") onDismiss(); }; document.addEventListener("mousedown", click); document.addEventListener("keydown", key); return () => { document.removeEventListener("mousedown", click); document.removeEventListener("keydown", key); }; }); } /* true once the page has scrolled — used to shadow the sticky header */ function useScrolled(threshold = 6) { const [scrolled, setScrolled] = useState(false); useEffect(() => { const on = () => setScrolled(window.scrollY > threshold); on(); window.addEventListener("scroll", on, { passive: true }); return () => window.removeEventListener("scroll", on); }, [threshold]); return scrolled; } /* poll a function on an interval + when the tab regains focus */ function usePoll(fn, ms) { const ref = useRef(fn); ref.current = fn; useEffect(() => { const tick = () => { if (!document.hidden) ref.current(); }; const id = setInterval(tick, ms); window.addEventListener("focus", tick); return () => { clearInterval(id); window.removeEventListener("focus", tick); }; }, [ms]); } /* ═══════════════════════════════════════════════════════ */ function App() { const [me, setMe] = useState(null); const [booting, setBooting] = useState(true); useEffect(() => { if (MODE === "intake") { setBooting(false); return; } // public form: no identity to fetch (async () => { const r = await api("me?mode=" + encodeURIComponent(MODE)); setMe(r.data?.user || { id: 0, role: "guest" }); setBooting(false); })(); }, []); const logout = async () => { const r = await api("logout", { method: "POST" }); setMe(r.data?.user || { id: 0, role: "guest" }); }; const updateUser = (patch) => setMe((prev) => ({ ...prev, ...patch })); if (booting) return Loading…; // Public request form — deliberately never touches the board. if (MODE === "intake") return ; const role = me?.role || "guest"; if (MODE === "admin") { if (role !== "admin") { return ; } return ; } // employee portal if (role === "guest") { return ; } return ; } /* ── page shell + shared styles ── */ function Shell({ children }) { return (
{children}
); } function Centered({ children }) { return

{children}

; } function GlobalStyle() { return ( ); } /* ── auth screen: employee (login+signup) or admin (login only) ── */ function AuthScreen({ kind, current, onAuth }) { const isAdmin = kind === "admin-login"; const [tab, setTab] = useState("login"); // login | signup | reset const [name, setName] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [busy, setBusy] = useState(false); const [err, setErr] = useState(""); const [offerReset, setOfferReset] = useState(false); const [resetSent, setResetSent] = useState(false); const wrongPortal = isAdmin && current && current.role !== "guest" && current.role !== "admin"; const goTab = (t) => { setTab(t); setErr(""); setOfferReset(false); setResetSent(false); }; const submit = async () => { setErr(""); setOfferReset(false); if (!email.trim() || !password) { setErr("Email and password are required."); return; } if (!isAdmin && tab === "signup" && !/@gmail\.com$/i.test(email.trim())) { setErr("Please use a Gmail (@gmail.com) address."); return; } setBusy(true); const path = (!isAdmin && tab === "signup") ? "signup" : "login"; const body = JSON.stringify({ name: name.trim(), email: email.trim(), password }); const r = await api(path, { method: "POST", body }); setBusy(false); if (!r.ok) { setErr(apiError(r, "Something went wrong. Try again.")); if (r.status === 409) setOfferReset(true); // email already exists return; } const user = r.data.user; if (isAdmin && user.role !== "admin") { setErr("That account isn't an administrator. Use a WordPress admin account."); return; } onAuth(user); }; const requestReset = async () => { setErr(""); if (!email.trim()) { setErr("Enter your email and we'll send a reset link."); return; } setBusy(true); await api("reset", { method: "POST", body: JSON.stringify({ email: email.trim() }) }); setBusy(false); setResetSent(true); }; const heading = tab === "reset" ? "Reset your password" : isAdmin ? "Admin sign in" : tab === "login" ? "Welcome back" : "Create your account"; const subheading = tab === "reset" ? "We'll email you a link to set a new password." : isAdmin ? "Sign in with a WordPress administrator account." : tab === "login" ? "Log in to see your team board." : "Sign up with your Gmail to join the board."; return (
{PHASES.map((p) => )}

DayBoard

{heading}

{subheading}

{!isAdmin && tab !== "reset" && (
{["login", "signup"].map((t) => ( ))}
)} {tab === "reset" ? (
{resetSent ? ( <>

If an account exists for {email.trim()}, a password-reset link is on its way. Check your inbox (and spam).

) : ( <> {err &&

{err}

}

)}
) : (
{!isAdmin && tab === "signup" && ( )} {(tab === "login" || isAdmin) && (

)} {wrongPortal && (

You're signed in as an employee. This is the admin portal — sign in with an administrator account, or use the employee board.

)} {err &&

{err}

} {offerReset && (

)} {!isAdmin && (

{tab === "login" ? <>New here? : <>Already have one? }

)}
)}
{isAdmin && TaskDB.employeeUrl && (

Looking for the team board? Employee portal →

)}
); } function Field({ label, value, onChange, type = "text", placeholder, onEnter }) { return ( ); } /* ═══════════ public request form (no login) ═══════════ Outside collaborators identify themselves once, then file tasks against a member and a date. They never see the board — only the names they can tag. */ const WHO_KEY = "taskdb_intake_who"; const SENT_KEY = "taskdb_intake_sent"; const readStore = (key, fallback) => { try { return JSON.parse(localStorage.getItem(key)) ?? fallback; } catch { return fallback; } }; const writeStore = (key, val) => { try { localStorage.setItem(key, JSON.stringify(val)); } catch { /* private mode — fine, just don't remember */ } }; function IntakeScreen() { const [who, setWho] = useState(() => readStore(WHO_KEY, null)); const [members, setMembers] = useState(null); // null = loading const [admins, setAdmins] = useState([]); const [closed, setClosed] = useState(false); const [sent, setSent] = useState(() => readStore(SENT_KEY, [])); useEffect(() => { (async () => { const r = await api("intake/members"); if (r.status === 403) { setClosed(true); setMembers([]); return; } setMembers((r.ok && r.data && r.data.members) || []); setAdmins((r.ok && r.data && r.data.admins) || []); })(); }, []); const saveWho = (w) => { setWho(w); writeStore(WHO_KEY, w); }; const remember = (entry) => { const next = [entry, ...sent].slice(0, 20); setSent(next); writeStore(SENT_KEY, next); }; return (
{PHASES.map((p) => )}

DayBoard

Send a task to the {TaskDB.siteName || "DayBoard"} team.

{closed ? (

This form is closed

The team isn't accepting requests through this link right now.

) : !who ? ( ) : ( saveWho(null)} /> )}
); } function IdentityForm({ onDone }) { const [name, setName] = useState(""); const [email, setEmail] = useState(""); const [err, setErr] = useState(""); const go = () => { if (!name.trim()) { setErr("Please enter your name."); return; } if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email.trim())) { setErr("Please enter a valid email address."); return; } onDone({ name: name.trim(), email: email.trim() }); }; return (

First, who are you?

We'll remember this on your device so you only fill it in once. No password, no account.

{err &&

{err}

}
); } function IntakeForm({ who, members, admins, sent, onSent, onForget }) { const [title, setTitle] = useState(""); const [note, setNote] = useState(""); const [user, setUser] = useState(""); const [prio, setPrio] = useState("medium"); const [date, setDate] = useState(todayISO()); const [pickerOpen, setPickerOpen] = useState(false); const [busy, setBusy] = useState(false); const [err, setErr] = useState(""); const [okMsg, setOkMsg] = useState(""); const pickRef = useRef(null); useDismiss(pickRef, () => { if (pickerOpen) setPickerOpen(false); }); const anyone = (members || []).length + (admins || []).length > 0; const toAdmin = (admins || []).some((a) => String(a.id) === String(user)); const submit = async () => { setErr(""); setOkMsg(""); if (!title.trim()) { setErr("Describe the task."); return; } if (!user) { setErr("Choose who it's for."); return; } setBusy(true); const r = await api("intake", { method: "POST", body: JSON.stringify({ name: who.name, email: who.email, title: title.trim(), note: note.trim(), user: Number(user), date, priority: prio, }), }); setBusy(false); if (!r.ok) { setErr(apiError(r, "Couldn't send that. Try again.")); return; } const to = [...(members || []), ...(admins || [])].find((m) => String(m.id) === String(user)); const toName = r.data?.assignedTo || (to && to.name) || "the team"; onSent({ title: title.trim(), to: to ? to.name : "", date, ts: Date.now() }); setOkMsg(toAdmin ? `Sent to ${toName} — it's on their admin task list for ${prettyDate(date)}.` : `Sent to ${toName} for ${prettyDate(date)}.`); setTitle(""); setNote(""); setPrio("medium"); }; return ( <> {/* no overflow-hidden here — it would clip the calendar popover */}

Add a task

Filing as {who.name} · {who.email}{" "}

Date {pickerOpen && ( setPickerOpen(false)} /> )}
{err &&

{err}

} {okMsg && (

✓ {okMsg}

)}
{sent.length > 0 && (

Sent from this device

    {sent.map((s, i) => (
  • {s.title} {s.to} · {dShort(s.date)}
  • ))}
)}

Tasks land on that person's board for the date you picked, tagged with your name.
Tag an admin instead and it goes to their admin task list.

); } /* ═══════════ calendar picker + period navigation ═══════════ mode 'daily' → value is a YYYY-MM-DD, picks that day mode 'weekly' → value is the Monday, picking any day snaps to its week mode 'monthly' → value is YYYY-MM, picks from a 12-month grid */ function DatePicker({ mode, value, onPick, onClose }) { const anchor = mode === "monthly" ? value + "-01" : value; const [cursor, setCursor] = useState(anchor.slice(0, 7)); // visible YYYY-MM const [year, setYear] = useState(Number(anchor.slice(0, 4))); const boxRef = useRef(null); const [up, setUp] = useState(false); // Open upward when there isn't room below, so the calendar is never cut off. useEffect(() => { const el = boxRef.current; if (!el) return; const r = el.getBoundingClientRect(); if (r.bottom > window.innerHeight - 8 && r.top > r.height) setUp(true); }, []); const today = todayISO(); const pick = (v) => { onPick(v); onClose(); }; const pos = `fade-in absolute right-0 z-50 ${up ? "bottom-full mb-2" : "mt-2"}`; /* month grid ─ used by the monthly mode */ if (mode === "monthly") { return (
{year}
{MONTHS_SHORT.map((label, i) => { const key = `${year}-${String(i + 1).padStart(2, "0")}`; const on = key === value; const isNow = key === thisMonthKey(); return ( ); })}
); } const cells = monthGrid(cursor); const weekEnd = mode === "weekly" ? shiftDate(value, 6) : null; const inSelectedWeek = (iso) => mode === "weekly" && iso >= value && iso <= weekEnd; return (
{monthLabel(cursor)}
{DOW_SHORT.map((d, i) => (
{d}
))}
{cells.map((c) => { const selected = mode === "daily" ? c.iso === value : inSelectedWeek(c.iso); const isToday = c.iso === today; return ( ); })}
); } /* ‹ label › where the label opens the calendar. The arrows still step one period at a time — the calendar is an addition, not a replacement. */ function PeriodNav({ mode, value, label, isCurrent, currentWord, onPrev, onNext, onPick, compact }) { const [open, setOpen] = useState(false); const ref = useRef(null); // The wrapper (not just the popover) owns dismissal, so a second click on the // label closes it instead of mousedown-closing then click-reopening. useDismiss(ref, () => { if (open) setOpen(false); }); return (
{open && setOpen(false)} />}
); } /* ═══════════════════════════════════════════════════════ */ function Board({ me, isAdmin, onLogout, onUpdateUser }) { const [date, setDate] = useState(todayISO()); const [board, setBoard] = useState(emptyBoard()); const [loading, setLoading] = useState(true); const [saveState, setSaveState] = useState("idle"); // idle | saving | saved | error const [manageOpen, setManageOpen] = useState(false); const [filterPrio, setFilterPrio] = useState("all"); const [view, setView] = useState("daily"); // daily | weekly | monthly | profile const [weekStart, setWeekStart] = useState(mondayOf(todayISO())); const [monthKey, setMonthKey] = useState(thisMonthKey()); const [assigned, setAssigned] = useState({ weekly: {}, monthly: {} }); const scrolled = useScrolled(); const boardRef = useRef(board); boardRef.current = board; const dateRef = useRef(date); dateRef.current = date; const savingRef = useRef(false); const isToday = date === todayISO(); const activePhase = currentPhase(); const canEdit = (memberId) => isAdmin || String(memberId) === String(me.id); /* ── load board for the day (silent = no spinner, used by auto-refresh) ── */ const load = useCallback(async (d, opts = {}) => { const { silent = false } = opts; if (!silent) setLoading(true); const [b, w, m] = await Promise.all([ api("board?date=" + encodeURIComponent(d)), api(`period?type=weekly&period=${mondayOf(d)}`), api(`period?type=monthly&period=${d.slice(0, 7)}`), ]); const newBoard = b.ok && b.data ? { members: b.data.members || [], tasks: b.data.tasks || {}, checkins: b.data.checkins || {} } : emptyBoard(); boardRef.current = newBoard; setBoard(newBoard); setAssigned({ weekly: (w.ok && w.data && w.data.tasks) ? w.data.tasks : {}, monthly: (m.ok && m.data && m.data.tasks) ? m.data.tasks : {}, }); if (!silent) setLoading(false); }, []); useEffect(() => { load(date); }, [date, load]); // Auto-refresh: poll quietly every 20s (skip while a save is in flight). usePoll(() => { if (!savingRef.current) load(dateRef.current, { silent: true }); }, 20000); /* ── persist one member's slice (server enforces ownership) ── */ const saveSlice = async (memberId, next) => { boardRef.current = next; // keep the ref current so rapid edits don't clobber each other savingRef.current = true; setBoard(next); setSaveState("saving"); const r = await api("slice", { method: "POST", body: JSON.stringify({ date, user: memberId, tasks: next.tasks[memberId] || [], checkins: next.checkins[memberId] || {}, }), }); savingRef.current = false; setSaveState(r.ok ? "saved" : "error"); if (r.ok) setTimeout(() => setSaveState("idle"), 1500); }; /* ── mutations (all scoped to a member) ── */ const addTask = (memberId, title, priority, source = null) => { if (!canEdit(memberId) || !title.trim()) return; const next = structuredClone(boardRef.current); if (!next.tasks[memberId]) next.tasks[memberId] = []; next.tasks[memberId].push({ id: uid(), title: title.trim(), priority, done: false, donePhase: null, createdAt: Date.now(), ...(source ? { source } : {}), }); saveSlice(memberId, next); }; const toggleTask = (memberId, taskId) => { if (!canEdit(memberId)) return; const next = structuredClone(boardRef.current); const t = (next.tasks[memberId] || []).find((x) => x.id === taskId); if (!t) return; t.done = !t.done; t.donePhase = t.done ? activePhase : null; if (t.done) t.inProgress = false; // done supersedes in-progress saveSlice(memberId, next); }; const toggleInProgress = (memberId, taskId) => { if (!canEdit(memberId)) return; const next = structuredClone(boardRef.current); const t = (next.tasks[memberId] || []).find((x) => x.id === taskId); if (!t) return; t.inProgress = !t.inProgress; if (t.inProgress) { t.done = false; t.donePhase = null; } // can't be in-progress and done saveSlice(memberId, next); }; const deleteTask = (memberId, taskId) => { if (!canEdit(memberId)) return; const list = boardRef.current.tasks[memberId] || []; const t = list.find((x) => x.id === taskId); if (!t || (t.admin && !isAdmin)) return; // admin-assigned tasks can't be removed const next = structuredClone(boardRef.current); next.tasks[memberId] = (next.tasks[memberId] || []).filter((x) => x.id !== taskId); saveSlice(memberId, next); }; const setTaskPriority = (memberId, taskId, priority) => { if (!canEdit(memberId)) return; const next = structuredClone(boardRef.current); const t = (next.tasks[memberId] || []).find((x) => x.id === taskId); if (!t) return; t.priority = priority; saveSlice(memberId, next); }; /* Rename a task. Admin-assigned tasks are read-only for the employee — the server enforces the same rule, this just avoids a pointless round trip. */ const setTaskTitle = (memberId, taskId, title) => { if (!canEdit(memberId) || !title.trim()) return; const next = structuredClone(boardRef.current); const t = (next.tasks[memberId] || []).find((x) => x.id === taskId); if (!t || (t.admin && !isAdmin)) return; t.title = title.trim(); saveSlice(memberId, next); }; const setTaskNote = (memberId, taskId, note) => { if (!canEdit(memberId)) return; const next = structuredClone(boardRef.current); const t = (next.tasks[memberId] || []).find((x) => x.id === taskId); if (!t) return; t.note = note; saveSlice(memberId, next); }; const toggleCheckin = (memberId, phaseId) => { if (!canEdit(memberId)) return; const next = structuredClone(boardRef.current); const cur = next.checkins[memberId]; next.checkins[memberId] = (cur && !Array.isArray(cur)) ? cur : {}; // guard: server may send [] for empty next.checkins[memberId][phaseId] = !next.checkins[memberId][phaseId]; saveSlice(memberId, next); }; const carryOver = async (memberId) => { if (!canEdit(memberId)) return; const r = await api("board?date=" + encodeURIComponent(shiftDate(date, -1))); const prevTasks = (r.data && r.data.tasks && r.data.tasks[memberId]) || []; const pending = prevTasks.filter((t) => !t.done); if (!pending.length) return; const next = structuredClone(boardRef.current); if (!next.tasks[memberId]) next.tasks[memberId] = []; const existing = new Set(next.tasks[memberId].map((t) => t.title)); pending.forEach((t) => { if (!existing.has(t.title)) { next.tasks[memberId].push({ ...t, id: uid(), done: false, donePhase: null, carried: true }); } }); saveSlice(memberId, next); }; const removeMember = async (id, name) => { if (!isAdmin) return; if (!window.confirm(`Remove ${name}? Their account and tasks will be deleted.`)) return; const r = await api("member", { method: "DELETE", body: JSON.stringify({ user: id }) }); if (r.ok) load(date); }; /* ── derived stats ── */ const { members } = board; const allTasks = members.flatMap((m) => board.tasks[m.id] || []); const doneCount = allTasks.filter((t) => t.done).length; const pct = allTasks.length ? Math.round((doneCount / allTasks.length) * 100) : 0; const highPending = allTasks.filter((t) => !t.done && t.priority === "high").length; const notCheckedIn = members.filter((m) => !board.checkins[m.id]?.[activePhase]); /* ── period navigation (adapts to the active view) ── */ let navMode, navValue, periodLabel, isCurrentPeriod, currentWord, prevPeriod, nextPeriod, pickPeriod; if (view === "daily") { navMode = "daily"; navValue = date; periodLabel = prettyDate(date); isCurrentPeriod = isToday; currentWord = "today"; prevPeriod = () => setDate(shiftDate(date, -1)); nextPeriod = () => setDate(shiftDate(date, 1)); pickPeriod = (v) => setDate(v); } else if (view === "weekly") { navMode = "weekly"; navValue = weekStart; periodLabel = "Week of " + weekLabel(weekStart); isCurrentPeriod = weekStart === mondayOf(todayISO()); currentWord = "this week"; prevPeriod = () => setWeekStart(shiftDate(weekStart, -7)); nextPeriod = () => setWeekStart(shiftDate(weekStart, 7)); pickPeriod = (v) => setWeekStart(v); } else { navMode = "monthly"; navValue = monthKey; periodLabel = monthLabel(monthKey); isCurrentPeriod = monthKey === thisMonthKey(); currentWord = "this month"; prevPeriod = () => setMonthKey(shiftMonth(monthKey, -1)); nextPeriod = () => setMonthKey(shiftMonth(monthKey, 1)); pickPeriod = (v) => setMonthKey(v); } const TABS = [["daily", "Daily"], ["weekly", "Weekly"], ["monthly", "Monthly"], ...(isAdmin ? [["report", "Team report"]] : [["profile", "My Profile"]])]; /* ─────────────────────────────────────── */ return ( {/* header + control bar travel together and stay pinned to the top */}
{PHASES.map((p) => )}

DayBoard

{isAdmin && ADMIN}
{saveState === "saving" ? "saving…" : saveState === "saved" ? "saved ✓" : saveState === "error" ? "save failed" : ""} {isAdmin && ( )}
{me.name || me.email}
{/* view tabs + period navigation — pinned under the header */}
{TABS.map(([v, label]) => ( ))}
{view !== "profile" && view !== "report" && (
)}
{/* manage team (admin only) */} {isAdmin && manageOpen && (

Team members

Employees join by signing up at the employee portal. Remove an account here.

{members.map((m) => ( {m.name} {m.email} ))} {!members.length && No employees yet — share the employee portal link so they can sign up.}
{TaskDB.employeeUrl && (

Employee portal: {TaskDB.employeeUrl}

)}
)} {view === "profile" && } {view === "report" && } {(view === "weekly" || view === "monthly") && ( )} {view === "daily" && <> {/* overview — pinned under the control bar (md+; on phones the wrapped header makes a fixed offset unreliable, so it just scrolls) */}
p.id === activePhase)?.label})`} value={isToday ? (notCheckedIn.length ? notCheckedIn.map((m) => m.name.split(" ")[0]).join(", ") : "Everyone in ✓") : "—"} small accent={isToday && notCheckedIn.length ? "#B47B14" : undefined} />
{/* filter */}
FILTER {["all", "high", "medium", "low"].map((p) => ( ))}
{/* member cards */} {loading ? ( Loading board… ) : !members.length ? (

No employees on the board yet

{isAdmin ? "Share the employee portal link so your team can sign up." : "Once teammates sign up they'll appear here."}

) : (
{members .slice() .sort((a, b) => (String(a.id) === String(me.id) ? -1 : String(b.id) === String(me.id) ? 1 : 0)) .map((m) => ( ))}
)}

Check-in rhythm: ● morning● post-lunch● end of day.
★ Blue rows are assigned by the admin — tick and annotate them, but they can't be edited or removed. {isAdmin ? " Admin view — you can edit everyone." : " Click any of your own task titles to edit them."}

}
); } /* ── stat card ── */ function Stat({ label, value, accent, small }) { return (
{label}
{value}
); } /* ── inline single-line editor: click the text, Enter or click-away saves, Escape cancels. Used for task titles, suggestions and requests. ── */ function InlineText({ value, editable, onSave, className = "", style, lockHint }) { const [editing, setEditing] = useState(false); const [val, setVal] = useState(value || ""); useEffect(() => { if (!editing) setVal(value || ""); }, [value, editing]); const commit = () => { setEditing(false); const v = val.trim(); if (v && v !== value) onSave(v); }; if (editing) { return ( setVal(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") commit(); if (e.key === "Escape") { setVal(value || ""); setEditing(false); } }} onBlur={commit} aria-label="Edit text" className="w-full flex-1 min-w-0 px-2 py-0.5 rounded border text-sm bg-white" style={{ borderColor: "#16191D" }} /> ); } return ( editable && setEditing(true)} title={editable ? "Click to edit" : lockHint || undefined} className={`${className} ${editable ? "editable-text" : ""}`} style={style}> {value} ); } /* ── member card ── */ /* ── per-task note (e.g. "in progress", "blocked") ── */ function TaskNote({ note, editable, onSave }) { const [editing, setEditing] = useState(false); const [val, setVal] = useState(note || ""); useEffect(() => { setVal(note || ""); }, [note]); const commit = () => { setEditing(false); const v = val.trim(); if (v !== (note || "")) onSave(v); }; if (!editable) { return note ?
📝 {note}
: null; } if (editing) { return (
setVal(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") commit(); if (e.key === "Escape") { setVal(note || ""); setEditing(false); } }} onBlur={commit} placeholder="e.g. in progress, blocked, waiting on…" className="w-full px-2 py-1 rounded border text-xs bg-white" style={{ borderColor: "#DDE0DC" }} />
); } return ( ); } function MemberCard({ member, tasks, checkins, filterPrio, isToday, activePhase, editable, isSelf, assignedWeekly = [], assignedMonthly = [], isAdminView, onAdd, onToggle, onDelete, onPriority, onCheckin, onCarry, onNote, onProgress, onRename }) { const [title, setTitle] = useState(""); const [prio, setPrio] = useState("medium"); const visible = tasks .filter((t) => filterPrio === "all" || t.priority === filterPrio) .sort((a, b) => (a.done - b.done) || (prioRank[a.priority] - prioRank[b.priority]) || (a.createdAt - b.createdAt)); const done = tasks.filter((t) => t.done).length; const fromAdmin = tasks.filter((t) => t.admin).length; const submit = () => { onAdd(member.id, title, prio); setTitle(""); }; // Assigned weekly/monthly tasks the member can pull into today (not yet done, // not already on today's list). const todayTitles = new Set(tasks.map((t) => t.title)); const assignedOptions = [ ...assignedWeekly.filter((t) => !t.done && !todayTitles.has(t.title)).map((t) => ({ source: "weekly", title: t.title })), ...assignedMonthly.filter((t) => !t.done && !todayTitles.has(t.title)).map((t) => ({ source: "monthly", title: t.title })), ]; const pickAssigned = (e) => { const i = e.target.value; if (i === "") return; const o = assignedOptions[i]; onAdd(member.id, o.title, prio, o.source); e.target.value = ""; }; return (
{initials(member.name)}

{member.name}{isSelf && YOU}

{done}/{tasks.length} done {fromAdmin > 0 && ( 1 ? "s" : ""} assigned by the admin`}> ★ {fromAdmin} )}
{/* tri-dot phase tracker */}
{PHASES.map((p) => { const on = !!checkins[p.id]; const isCurrent = isToday && p.id === activePhase; return ( ); })}
    {visible.map((t) => { const P = PRIORITIES.find((x) => x.id === t.priority) || PRIORITIES[1]; const phase = PHASES.find((p) => p.id === t.donePhase); // Assigned by the admin: the employee may tick, annotate and // re-prioritise it, but not rename or delete it. const locked = !!t.admin && !isAdminView; return (
  • onRename(member.id, t.id, v)} lockHint="Assigned by the admin — you can tick it off but not change it" className={t.done ? "line-through" : ""} style={{ color: t.done ? "#9AA1A9" : "#16191D" }} /> {t.admin && ( ★ {ADMIN_TASK.label} )} {t.external && ( ↗ {t.byName || "external"} )} {t.source && ◆ {t.source}} {t.carried && ↻ carried} {(() => { const wip = !!t.inProgress && !t.done; if (!editable) { return wip ? ● In progress : null; } if (t.done) return null; return ( ); })()} {phase && ( )} {editable ? ( ) : ( {P.label} )} {editable && (locked ? ( 🔒 ) : ( ))}
    onNote(member.id, t.id, v)} />
  • ); })} {!visible.length && (
  • {tasks.length ? "No tasks match this filter." : "No tasks yet for this day."}
  • )}
{editable && (
setTitle(e.target.value)} onKeyDown={(e) => e.key === "Enter" && submit()} placeholder="Add a task…" className="flex-1 min-w-0 px-3 py-1.5 rounded-lg border text-sm bg-white" style={{ borderColor: "#DDE0DC" }} /> {assignedOptions.length > 0 && ( )}
)}
); } /* ═══════════ weekly / monthly view ═══════════ */ function PeriodView({ type, period, me, isAdmin, setSaveState }) { const [data, setData] = useState({ members: [], tasks: {} }); const [loading, setLoading] = useState(true); const busyRef = useRef(false); const periodRef = useRef(period); periodRef.current = period; const load = useCallback(async (p, silent) => { if (!silent) setLoading(true); const r = await api(`period?type=${type}&period=${encodeURIComponent(p)}`); setData(r.ok && r.data ? { members: r.data.members || [], tasks: r.data.tasks || {} } : { members: [], tasks: {} }); if (!silent) setLoading(false); }, [type]); useEffect(() => { load(period); }, [period, load]); usePoll(() => { if (!busyRef.current) load(periodRef.current, true); }, 25000); const flash = (ok) => { setSaveState(ok ? "saved" : "error"); if (ok) setTimeout(() => setSaveState("idle"), 1500); }; const addTask = async (user, title) => { if (!isAdmin || !title.trim()) return; busyRef.current = true; setSaveState("saving"); const r = await api("period", { method: "POST", body: JSON.stringify({ action: "add", type, period, user, title: title.trim() }) }); busyRef.current = false; flash(r.ok); if (r.ok) load(period, true); }; const toggle = async (user, taskId) => { if (!(isAdmin || String(user) === String(me.id))) return; setData((prev) => { const n = structuredClone(prev); const t = (n.tasks[user] || []).find((x) => x.id === taskId); if (t) t.done = !t.done; return n; }); busyRef.current = true; setSaveState("saving"); const r = await api("period", { method: "POST", body: JSON.stringify({ action: "toggle", type, period, user, taskId }) }); busyRef.current = false; flash(r.ok); if (!r.ok) load(period, true); }; const rename = async (user, taskId, title) => { if (!isAdmin || !title.trim()) return; setData((prev) => { const n = structuredClone(prev); const t = (n.tasks[user] || []).find((x) => x.id === taskId); if (t) t.title = title.trim(); return n; }); busyRef.current = true; setSaveState("saving"); const r = await api("period", { method: "POST", body: JSON.stringify({ action: "rename", type, period, user, taskId, title: title.trim() }) }); busyRef.current = false; flash(r.ok); if (!r.ok) load(period, true); }; const del = async (user, taskId) => { if (!isAdmin) return; setData((prev) => { const n = structuredClone(prev); n.tasks[user] = (n.tasks[user] || []).filter((x) => x.id !== taskId); return n; }); busyRef.current = true; setSaveState("saving"); const r = await api("period", { method: "POST", body: JSON.stringify({ action: "delete", type, period, user, taskId }) }); busyRef.current = false; flash(r.ok); if (!r.ok) load(period, true); }; const all = data.members.flatMap((m) => data.tasks[m.id] || []); const done = all.filter((t) => t.done).length; const pct = all.length ? Math.round((done / all.length) * 100) : 0; const word = type === "weekly" ? "Weekly" : "Monthly"; return ( <>
{isAdmin && }
{loading ? ( Loading… ) : !data.members.length ? (

{isAdmin ? "No employees yet" : `No ${word.toLowerCase()} tasks`}

{isAdmin ? "Members appear here once they sign up." : "Your admin hasn't assigned any yet."}

) : (
{data.members .slice() .sort((a, b) => (String(a.id) === String(me.id) ? -1 : String(b.id) === String(me.id) ? 1 : 0)) .map((m) => ( ))}
)}

{isAdmin ? `Assign each member's ${word.toLowerCase()} tasks; they tick off what they complete.` : `Your ${word.toLowerCase()} tasks, assigned by your admin. Pull one into a day from the Daily tab; tick it here when fully done.`}

); } function PeriodCard({ member, tasks, isSelf, canTick, canAssign, onAdd, onToggle, onDelete, onRename }) { const [title, setTitle] = useState(""); const done = tasks.filter((t) => t.done).length; const pct = tasks.length ? Math.round((done / tasks.length) * 100) : 0; const sorted = tasks.slice().sort((a, b) => (a.done - b.done) || (a.createdAt - b.createdAt)); const submit = () => { onAdd(member.id, title); setTitle(""); }; return (
{initials(member.name)}

{member.name}{isSelf && YOU}

{done}/{tasks.length} done · {pct}%
    {sorted.map((t) => (
  • onRename(member.id, t.id, v)} lockHint="Assigned by the admin" className={t.done ? "line-through" : ""} style={{ color: t.done ? "#9AA1A9" : "#16191D" }} /> {canAssign && ( )}
  • ))} {!tasks.length && (
  • {canAssign ? "No tasks assigned yet — add one below." : "No tasks assigned yet."}
  • )}
{canAssign && (
setTitle(e.target.value)} onKeyDown={(e) => e.key === "Enter" && submit()} placeholder="Assign a task…" className="flex-1 min-w-0 px-3 py-1.5 rounded-lg border text-sm bg-white" style={{ borderColor: "#DDE0DC" }} />
)}
); } /* ═══════════ personal profile (weekly / monthly task breakdown) ═══════════ */ const dShort = (iso) => { const [y, m, d] = iso.split("-").map(Number); return new Date(y, m - 1, d).toLocaleDateString("en-IN", { weekday: "short", day: "numeric", month: "short" }); }; function ProfileView({ me, onRename }) { const [scope, setScope] = useState("weekly"); // weekly | monthly const [anchor, setAnchor] = useState(todayISO()); const [data, setData] = useState(null); const [editingName, setEditingName] = useState(false); const [nameVal, setNameVal] = useState(me.name || ""); const [savingName, setSavingName] = useState(false); const saveName = async () => { const v = nameVal.trim(); if (!v || v === me.name) { setEditingName(false); return; } setSavingName(true); const r = await api("rename", { method: "POST", body: JSON.stringify({ name: v }) }); setSavingName(false); setEditingName(false); if (r.ok && onRename) onRename({ name: r.data.name || v }); }; const load = useCallback(async (sc, a) => { setData(null); const r = await api(`profile?scope=${sc}&date=${encodeURIComponent(a)}`); setData(r.ok && r.data ? r.data : { counts: { done: 0, inProgress: 0, pending: 0 }, buckets: { done: [], inProgress: [], pending: [] } }); }, []); useEffect(() => { load(scope, anchor); }, [scope, anchor, load]); const isCurrent = scope === "weekly" ? mondayOf(anchor) === mondayOf(todayISO()) : anchor.slice(0, 7) === thisMonthKey(); const label = scope === "weekly" ? "Week of " + weekLabel(mondayOf(anchor)) : monthLabel(anchor.slice(0, 7)); const prev = () => setAnchor(scope === "weekly" ? shiftDate(anchor, -7) : shiftMonth(anchor.slice(0, 7), -1) + "-01"); const next = () => setAnchor(scope === "weekly" ? shiftDate(anchor, 7) : shiftMonth(anchor.slice(0, 7), 1) + "-01"); const counts = data ? data.counts : { done: 0, inProgress: 0, pending: 0 }; const total = counts.done + counts.inProgress + counts.pending; const pct = total ? Math.round((counts.done / total) * 100) : 0; const GROUPS = [ { key: "done", label: "Completed", color: "#3DA164" }, { key: "inProgress", label: "In progress", color: "#B47B14" }, { key: "pending", label: "Not completed", color: "#5B6470" }, ]; return ( <>
{[["weekly", "This week"], ["monthly", "This month"]].map(([s, l]) => ( ))}
setAnchor(scope === "weekly" ? v : v + "-01")} />
{initials(me.name)}
{editingName ? (
setNameVal(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") saveName(); if (e.key === "Escape") { setNameVal(me.name || ""); setEditingName(false); } }} className="px-2 py-1 rounded border text-sm bg-white" style={{ borderColor: "#DDE0DC" }} />
) : (

{me.name}

)}
{me.email}
{data === null ? ( Loading… ) : !total ? (

No tasks {scope === "weekly" ? "this week" : "this month"}

Add tasks on the Daily tab and they'll show up here.

) : (
{GROUPS.map((g) => { const items = (data.buckets[g.key] || []).slice().sort((a, b) => a.date < b.date ? -1 : a.date > b.date ? 1 : 0); return (

{g.label}

{items.length}
    {items.map((t, i) => { const P = PRIORITIES.find((x) => x.id === t.priority) || PRIORITIES[1]; return (
  • {t.title} {t.admin && ★ {ADMIN_TASK.label}} {P.label}
    {dShort(t.date)}{t.note ? · 📝 {t.note} : null}
  • ); })} {!items.length &&
  • None.
  • }
); })}
)}

Your daily tasks across the {scope === "weekly" ? "week" : "month"}. A task counts once per day it appears on.

); } /* ═══════════ admin: what each employee completed ═══════════ */ function TeamReport() { const [scope, setScope] = useState("daily"); // daily | weekly | monthly const [anchor, setAnchor] = useState(todayISO()); const [data, setData] = useState(null); const [openId, setOpenId] = useState(null); const scopeRef = useRef({ scope, anchor }); scopeRef.current = { scope, anchor }; const load = useCallback(async (sc, a, silent) => { if (!silent) setData(null); const r = await api(`report?scope=${sc}&date=${encodeURIComponent(a)}`); setData(r.ok && r.data ? r.data : { members: [], totals: { done: 0, inProgress: 0, pending: 0, total: 0, pct: 0 } }); }, []); useEffect(() => { load(scope, anchor); }, [scope, anchor, load]); // A monthly report reads 31 days x every member, so refresh it far less // eagerly than the live board. Refresh also fires on tab focus. usePoll(() => load(scopeRef.current.scope, scopeRef.current.anchor, true), 60000); /* one nav for three scopes */ const nav = { daily: { mode: "daily", value: anchor, label: prettyDate(anchor), isCurrent: anchor === todayISO(), word: "today", prev: () => setAnchor(shiftDate(anchor, -1)), next: () => setAnchor(shiftDate(anchor, 1)), pick: (v) => setAnchor(v), }, weekly: { mode: "weekly", value: mondayOf(anchor), label: "Week of " + weekLabel(mondayOf(anchor)), isCurrent: mondayOf(anchor) === mondayOf(todayISO()), word: "this week", prev: () => setAnchor(shiftDate(anchor, -7)), next: () => setAnchor(shiftDate(anchor, 7)), pick: (v) => setAnchor(v), }, monthly: { mode: "monthly", value: anchor.slice(0, 7), label: monthLabel(anchor.slice(0, 7)), isCurrent: anchor.slice(0, 7) === thisMonthKey(), word: "this month", prev: () => setAnchor(shiftMonth(anchor.slice(0, 7), -1) + "-01"), next: () => setAnchor(shiftMonth(anchor.slice(0, 7), 1) + "-01"), pick: (v) => setAnchor(v + "-01"), }, }[scope]; const totals = data ? data.totals : { done: 0, inProgress: 0, pending: 0, total: 0, pct: 0 }; // Best completion rate first, but a member with nothing assigned isn't "100%". const rows = (data ? data.members : []).slice().sort((a, b) => (b.total ? b.pct : -1) - (a.total ? a.pct : -1) || b.done - a.done); return ( <>
{[["daily", "Daily"], ["weekly", "Weekly"], ["monthly", "Monthly"]].map(([s, l]) => ( ))}
{data === null ? ( Loading report… ) : !rows.length ? (

No employees yet

Members appear here once they sign up.

) : (
{rows.map((m, i) => { const isOpen = openId === m.id; return (
{isOpen && (
Completed {scope === "daily" ? "this day" : scope === "weekly" ? "this week" : "this month"} ({m.done})
{!m.completed.length ? (

Nothing completed in this period.

) : (
    {m.completed.map((t, k) => { const P = PRIORITIES.find((x) => x.id === t.priority) || PRIORITIES[1]; return (
  • {t.title} {t.admin && ★ from admin} {t.note && 📝 {t.note}} {P.label} {scope !== "daily" && {dShort(t.date)}}
  • ); })}
)}
)}
); })}
)}

Completion is measured across each member's daily tasks for the selected period. Click a member to see exactly what they finished.

); } /* ═══════════ side panels: tasks for the admin + suggestions ═══════════ Both live in one card behind a tab strip so the sidebar stays a readable height. Both stay mounted while hidden so their counts keep polling. */ function SidePanels({ me, isAdmin, setSaveState }) { const [tab, setTab] = useState("requests"); const [counts, setCounts] = useState({ requests: 0, suggestions: 0 }); const count = useCallback((key, n) => setCounts((c) => (c[key] === n ? c : { ...c, [key]: n })), []); const TABS = [ ["requests", "For the admin"], ["suggestions", "Suggestions"], ]; return (
{TABS.map(([id, label]) => { const on = tab === id; return ( ); })}
count("requests", n)} />
count("suggestions", n)} />
); } /* ═══════════ tasks employees assign UP to an admin ═══════════ */ function Requests({ me, isAdmin, setSaveState, onCount }) { const [list, setList] = useState(null); // null = loading const [admins, setAdmins] = useState([]); const [text, setText] = useState(""); const [to, setTo] = useState("0"); const [prio, setPrio] = useState("medium"); const busyRef = useRef(false); const load = useCallback(async () => { const r = await api("requests"); if (!r.ok || !r.data) { setList([]); return; } setList(r.data.requests || []); setAdmins(r.data.admins || []); }, []); useEffect(() => { load(); }, [load]); usePoll(() => { if (!busyRef.current) load(); }, 25000); const open = (list || []).filter((s) => !s.done).length; useEffect(() => { if (onCount) onCount(open); }, [open, onCount]); const flash = (ok) => { setSaveState(ok ? "saved" : "error"); if (ok) setTimeout(() => setSaveState("idle"), 1500); }; const send = async (body, optimistic) => { if (optimistic) setList(optimistic); busyRef.current = true; setSaveState("saving"); const r = await api("requests", { method: "POST", body: JSON.stringify(body) }); busyRef.current = false; flash(r.ok); if (!r.ok || !optimistic) load(); return r; }; const add = async () => { if (!text.trim()) return; const r = await send({ action: "add", text: text.trim(), to: Number(to), priority: prio }); if (r.ok) { setText(""); setPrio("medium"); } }; const toggle = (id) => { if (!isAdmin) return; send({ action: "toggle", id }, list.map((s) => (s.id === id ? { ...s, done: !s.done } : s))); }; const edit = (id, v) => send({ action: "edit", id, text: v }, list.map((s) => (s.id === id ? { ...s, text: v } : s))); const del = (id) => send({ action: "delete", id }, list.filter((s) => s.id !== id)); const items = (list || []).slice().sort((a, b) => (a.done - b.done) || (prioRank[a.priority] - prioRank[b.priority]) || (a.createdAt - b.createdAt)); return (

Tasks for the admin {open > 0 && {open} open}

{isAdmin ? "Raised by the team for you — tick them off as you go." : "Need something from an admin? Assign it here and tag who it's for."}

setText(e.target.value)} onKeyDown={(e) => e.key === "Enter" && add()} placeholder="e.g. Approve the leave request…" className="w-full px-3 py-1.5 rounded-lg border text-sm bg-white" style={{ borderColor: "#DDE0DC" }} />
    {list === null ? (
  • Loading…
  • ) : !items.length ? (
  • {isAdmin ? "Nothing requested yet." : "Nothing yet — assign your first task to an admin above."}
  • ) : items.map((s) => { const P = PRIORITIES.find((x) => x.id === s.priority) || PRIORITIES[1]; const isMine = String(s.byId) === String(me.id); const canEdit = isAdmin || isMine; return (
  • edit(s.id, v)} className={`text-sm flex-1 min-w-0 ${s.done ? "line-through" : ""}`} style={{ color: s.done ? "#9AA1A9" : "#16191D" }} /> {!s.done && ( {P.label} )}
    — {s.by}{isMine ? " (you)" : ""} {s.external && ↗ external} @ {s.to || "any admin"} {s.date && · due {dShort(s.date)}}
    {s.note && (
    📝 {s.note}
    )}
    {canEdit && ( )}
  • ); })}
); } /* ═══════════ suggestions panel ═══════════ */ function Suggestions({ me, isAdmin, setSaveState, onCount }) { const [list, setList] = useState(null); // null = loading const [text, setText] = useState(""); const busyRef = useRef(false); const load = useCallback(async () => { const r = await api("suggestions"); setList(r.ok && r.data ? (r.data.suggestions || []) : []); }, []); useEffect(() => { load(); }, [load]); usePoll(() => { if (!busyRef.current) load(); }, 25000); const flash = (ok) => { setSaveState(ok ? "saved" : "error"); if (ok) setTimeout(() => setSaveState("idle"), 1500); }; const add = async () => { if (!text.trim()) return; busyRef.current = true; setSaveState("saving"); const r = await api("suggestions", { method: "POST", body: JSON.stringify({ action: "add", text: text.trim() }) }); busyRef.current = false; flash(r.ok); if (r.ok) { setText(""); load(); } }; const toggle = async (id) => { if (!isAdmin) return; setList((prev) => prev.map((s) => (s.id === id ? { ...s, done: !s.done } : s))); busyRef.current = true; setSaveState("saving"); const r = await api("suggestions", { method: "POST", body: JSON.stringify({ action: "toggle", id }) }); busyRef.current = false; flash(r.ok); if (!r.ok) load(); }; const edit = async (id, v) => { setList((prev) => prev.map((s) => (s.id === id ? { ...s, text: v } : s))); busyRef.current = true; setSaveState("saving"); const r = await api("suggestions", { method: "POST", body: JSON.stringify({ action: "edit", id, text: v }) }); busyRef.current = false; flash(r.ok); if (!r.ok) load(); }; const del = async (id) => { setList((prev) => prev.filter((s) => s.id !== id)); busyRef.current = true; setSaveState("saving"); const r = await api("suggestions", { method: "POST", body: JSON.stringify({ action: "delete", id }) }); busyRef.current = false; flash(r.ok); if (!r.ok) load(); }; const items = (list || []).slice().sort((a, b) => (a.done - b.done) || (a.createdAt - b.createdAt)); const open = (list || []).filter((s) => !s.done).length; useEffect(() => { if (onCount) onCount(open); }, [open, onCount]); return (

Suggestions {open > 0 && {open} open}

{isAdmin ? "Ideas from the team — tick the ones you've done." : "Share an idea. The developer ticks it off when it's done."}

setText(e.target.value)} onKeyDown={(e) => e.key === "Enter" && add()} placeholder="Write a suggestion…" className="flex-1 min-w-0 px-3 py-1.5 rounded-lg border text-sm bg-white" style={{ borderColor: "#DDE0DC" }} />
    {list === null ? (
  • Loading…
  • ) : !items.length ? (
  • No suggestions yet.
  • ) : items.map((s) => { const canDelete = isAdmin || String(s.byId) === String(me.id); return (
  • edit(s.id, v)} className={`text-sm ${s.done ? "line-through" : ""}`} style={{ color: s.done ? "#9AA1A9" : "#16191D" }} />
    — {s.by}{String(s.byId) === String(me.id) ? " (you)" : ""}
    {canDelete && ( )}
  • ); })}
); } /* ═══════════ notification bell (persistent inbox) ═══════════ */ const timeAgo = (ts) => { if (!ts) return ""; const s = Math.floor((Date.now() - ts) / 1000); if (s < 60) return "just now"; const m = Math.floor(s / 60); if (m < 60) return m + "m ago"; const h = Math.floor(m / 60); if (h < 24) return h + "h ago"; const d = Math.floor(h / 24); return d + "d ago"; }; const dayBucket = (ts) => { const now = new Date(); const startToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); if (ts >= startToday) return "Today"; if (ts >= startToday - 86400000) return "Yesterday"; return "Earlier"; }; const noteIcon = (title) => { const t = (title || "").toLowerCase(); if (t.includes("assigned")) return "🗓️"; if (t.includes("updated")) return "✏️"; if (t.includes("new task")) return "📋"; return "🔔"; }; function NotificationBell() { const [items, setItems] = useState([]); const [unread, setUnread] = useState(0); const [open, setOpen] = useState(false); const [toast, setToast] = useState(null); const prevUnread = useRef(0); const load = useCallback(async () => { const r = await api("notifications"); if (!r.ok || !r.data) return; const list = r.data.notifications || []; const u = r.data.unread || 0; if (u > prevUnread.current) { const newest = list.find((n) => !n.read); if (newest) { setToast(newest); setTimeout(() => setToast(null), 6000); } } prevUnread.current = u; setItems(list); setUnread(u); }, []); useEffect(() => { load(); }, [load]); usePoll(load, 20000); const toggle = async () => { const next = !open; setOpen(next); if (next && unread > 0) { await api("notifications", { method: "POST", body: JSON.stringify({ action: "read_all" }) }); setItems((xs) => xs.map((n) => ({ ...n, read: true }))); setUnread(0); prevUnread.current = 0; } }; const clearAll = async () => { await api("notifications", { method: "POST", body: JSON.stringify({ action: "clear" }) }); setItems([]); setUnread(0); prevUnread.current = 0; }; return (
{open && (
Notifications
{unread > 0 ? `${unread} unread` : "You're all caught up"}
{items.length > 0 && }
    {!items.length ? (
  • No notifications yet.
  • ) : items.map((n, i) => { const bucket = dayBucket(n.ts); const showHead = i === 0 || dayBucket(items[i - 1].ts) !== bucket; return ( {showHead && (
  • {bucket}
  • )}
  • {noteIcon(n.title)}
    {n.title || "Notification"} {timeAgo(n.ts)}
    {n.text &&
    {n.text}
    }
  • ); })}
)} {toast && (
{noteIcon(toast.title)}
{toast.title || "Notification"}
{toast.text &&
{toast.text}
}
)}
); } /* ── mount ── */ const taskDbRoot = document.getElementById("task-db-root"); if (taskDbRoot) { ReactDOM.createRoot(taskDbRoot).render(); }