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
{!isAdmin && tab !== "reset" && (
{["login", "signup"].map((t) => (
goTab(t)}
className="px-3 py-1.5 rounded-lg text-sm font-medium capitalize"
style={tab === t ? { background: "#16191D", color: "#fff" } : { background: "#EEF0F2", color: "#5B6470" }}>
{t === "login" ? "Log in" : "Sign up"}
))}
)}
{tab === "reset" ? (
{resetSent ? (
<>
If an account exists for {email.trim()} , a password-reset link is on its way. Check your inbox (and spam).
goTab("login")}
className="w-full py-2.5 rounded-lg text-sm font-semibold text-white" style={{ background: "#16191D" }}>
Back to log in
>
) : (
<>
{err &&
{err}
}
{busy ? "Sending…" : "Send reset link"}
goTab("login")}>Back to log in
>
)}
) : (
{!isAdmin && tab === "signup" && (
)}
{(tab === "login" || isAdmin) && (
goTab("reset")}>Forgot password?
)}
{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 && (
goTab(tab === "signup" ? "login" : "reset")}>
{tab === "signup" ? "Log in instead" : "Reset your password →"}
)}
{busy ? "Please wait…" : isAdmin ? "Sign in" : tab === "login" ? "Log in" : "Create account & enter"}
{!isAdmin && (
{tab === "login"
? <>New here? goTab("signup")}>Create an account >
: <>Already have one? goTab("login")}>Log in >}
)}
)}
{isAdmin && TaskDB.employeeUrl && (
Looking for the team board? Employee portal →
)}
);
}
function Field({ label, value, onChange, type = "text", placeholder, onEnter }) {
return (
{label}
onChange(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onEnter && onEnter()}
placeholder={placeholder}
className="mt-1 w-full px-3 py-2 rounded-lg border text-sm bg-white" style={{ borderColor: "#DDE0DC" }} />
);
}
/* ═══════════ 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.
);
}
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}{" "}
not you?
Task
setTitle(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submit()}
placeholder="What needs doing?"
className="mt-1 w-full px-3 py-2 rounded-lg border text-sm bg-white" style={{ borderColor: "#DDE0DC" }} />
Assign to
setUser(e.target.value)}
className="mt-1 w-full px-3 py-2 rounded-lg border text-sm bg-white" style={{ borderColor: "#DDE0DC" }}>
{members === null ? "Loading…" : anyone ? "Choose a person…" : "Nobody available yet"}
{(members || []).length > 0 && (
{members.map((m) => {m.name} )}
)}
{(admins || []).length > 0 && (
{admins.map((a) => {a.name} )}
)}
Date
setPickerOpen((o) => !o)}
className="tdb-plain mt-1 w-full px-3 py-2 rounded-lg border text-sm text-left flex items-center gap-2"
style={{ borderColor: "#DDE0DC", color: "#16191D" }}>
🗓
{prettyDate(date)}
{date === todayISO() && ● today }
{pickerOpen && (
setPickerOpen(false)} />
)}
Priority
setPrio(e.target.value)}
className="mt-1 w-full px-3 py-2 rounded-lg border text-sm bg-white" style={{ borderColor: "#DDE0DC" }}>
{PRIORITIES.map((p) => {p.label} )}
Note (optional)
setNote(e.target.value)}
placeholder="Context, link, deadline detail…"
className="mt-1 w-full px-3 py-2 rounded-lg border text-sm bg-white" style={{ borderColor: "#DDE0DC" }} />
{err &&
{err}
}
{okMsg && (
✓ {okMsg}
)}
{busy ? "Sending…" : "Send task"}
{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 (
setYear(year - 1)} aria-label="Previous year" className="tdb-ghost px-2 py-1 rounded">‹
{year}
setYear(year + 1)} aria-label="Next year" className="tdb-ghost px-2 py-1 rounded">›
{MONTHS_SHORT.map((label, i) => {
const key = `${year}-${String(i + 1).padStart(2, "0")}`;
const on = key === value;
const isNow = key === thisMonthKey();
return (
pick(key)}
className="py-2 rounded-lg text-xs font-medium transition-colors"
style={on
? { background: "#16191D", color: "#fff" }
: { background: isNow ? "#E7F4EC" : "transparent", color: isNow ? "#2C7A4B" : "#16191D" }}>
{label}
);
})}
pick(thisMonthKey())}
className="tdb-plain mt-2 w-full py-1.5 rounded-lg text-xs font-medium border" style={{ borderColor: "#DDE0DC", color: "#5B6470" }}>
This month
);
}
const cells = monthGrid(cursor);
const weekEnd = mode === "weekly" ? shiftDate(value, 6) : null;
const inSelectedWeek = (iso) => mode === "weekly" && iso >= value && iso <= weekEnd;
return (
setCursor(shiftMonth(cursor, -1))} aria-label="Previous month"
className="tdb-ghost px-2 py-1 rounded">‹
{monthLabel(cursor)}
setCursor(shiftMonth(cursor, 1))} aria-label="Next month"
className="tdb-ghost px-2 py-1 rounded">›
{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 (
pick(mode === "weekly" ? mondayOf(c.iso) : c.iso)}
title={prettyDate(c.iso)}
className="h-8 rounded-lg text-xs font-medium transition-colors hover:bg-black/5"
style={{
background: selected ? "#16191D" : "transparent",
color: selected ? "#fff" : c.inMonth ? "#16191D" : "#C4C9C4",
boxShadow: isToday && !selected ? "inset 0 0 0 1.5px #3DA164" : "none",
}}>
{c.day}
);
})}
pick(mode === "weekly" ? mondayOf(today) : today)}
className="mt-2 w-full py-1.5 rounded-lg text-xs font-medium border" style={{ borderColor: "#DDE0DC", color: "#5B6470" }}>
{mode === "weekly" ? "This week" : "Today"}
);
}
/* ‹ 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 (
‹
setOpen((o) => !o)} aria-expanded={open} aria-haspopup="dialog"
title="Open calendar"
className={`tdb-ghost mono ${compact ? "text-xs" : "text-sm"} px-3 py-1 rounded flex items-center gap-1.5`}>
🗓
{label}
{isCurrent && ● {currentWord} }
›
{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" : ""}
load(date)} className="text-xs px-3 py-1.5 rounded border border-white/20 text-white hover:bg-white/10">
Refresh
{isAdmin && (
setManageOpen(!manageOpen)}
className="text-xs px-3 py-1.5 rounded font-medium" style={{ background: "#FFFFFF", color: "#16191D" }}>
Manage team
)}
{me.name || me.email}
Log out
{/* view tabs + period navigation — pinned under the header */}
{TABS.map(([v, label]) => (
setView(v)}
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
style={view === v ? { background: "#16191D", color: "#fff" } : { background: "transparent", color: "#5B6470" }}>
{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}
removeMember(m.id, m.name)} aria-label={`Remove ${m.name}`}
className="w-5 h-5 rounded-full text-xs hover:bg-red-50" style={{ color: "#E8553F" }}>✕
))}
{!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) => (
setFilterPrio(p)}
className="px-3 py-1 rounded-full text-xs font-medium border capitalize"
style={filterPrio === p
? { background: "#16191D", color: "#fff", borderColor: "#16191D" }
: { background: "#fff", color: "#5B6470", borderColor: "#DDE0DC" }}>
{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 (
);
}
/* ── 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 (
setEditing(true)} className="ml-7 mt-1 block text-xs text-left"
style={{ color: note ? "#7A5AF8" : "#9AA1A9" }}>
{note ? <>📝 {note}> : "+ note"}
);
}
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 (
editable && onCheckin(member.id, p.id)}
disabled={!editable}
title={`${p.label} check-in${editable ? (on ? " (done — click to undo)" : "") : " (read-only)"}`}
className="w-7 h-7 rounded-full border-2 flex items-center justify-center text-[10px] font-bold transition-all"
style={{
borderColor: p.color,
background: on ? p.color : "#fff",
color: on ? "#fff" : p.color,
cursor: editable ? "pointer" : "default",
opacity: editable || on ? 1 : 0.55,
boxShadow: isCurrent && !on && editable ? `0 0 0 3px ${p.color}33` : "none",
}}>
{on ? "✓" : ""}
);
})}
{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 (
editable && onToggle(member.id, t.id)} disabled={!editable}
aria-label={t.done ? "Mark not done" : "Mark done"}
className="w-5 h-5 rounded border-2 flex items-center justify-center text-[11px] shrink-0 transition-all"
style={{
borderColor: t.done ? "#3DA164" : "#C4C9C4",
background: t.done ? "#3DA164" : "#fff",
color: "#fff",
cursor: editable ? "pointer" : "default",
}}>
{t.done ? "✓" : ""}
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 (
onProgress(member.id, t.id)} title="Toggle 'in progress'"
className="text-[10px] font-medium rounded-full px-2 py-0.5 border shrink-0 transition-colors"
style={wip
? { background: "#FCF1DC", color: "#B47B14", borderColor: "#F2D8A0" }
: { background: "#fff", color: "#9AA1A9", borderColor: "#DDE0DC" }}>
● In progress
);
})()}
{phase && (
)}
{editable ? (
onPriority(member.id, t.id, e.target.value)}
aria-label="Priority"
className="text-[11px] font-medium rounded-full px-2 py-0.5 border-0 cursor-pointer"
style={{ background: P.bg, color: P.color }}>
{PRIORITIES.map((p) => {p.label} )}
) : (
{P.label}
)}
{editable && (locked ? (
🔒
) : (
onDelete(member.id, t.id)} aria-label="Delete task"
className="del-btn opacity-0 transition-opacity w-5 h-5 text-xs rounded hover:bg-red-50 shrink-0"
style={{ color: "#E8553F" }}>✕
))}
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 && (
+ Assigned…
{assignedOptions.some((o) => o.source === "weekly") && (
{assignedOptions.map((o, i) => o.source === "weekly" && {o.title} )}
)}
{assignedOptions.some((o) => o.source === "monthly") && (
{assignedOptions.map((o, i) => o.source === "monthly" && {o.title} )}
)}
)}
setPrio(e.target.value)} aria-label="New task priority"
className="text-xs rounded-lg border px-2 bg-white" style={{ borderColor: "#DDE0DC" }}>
{PRIORITIES.map((p) => {p.label} )}
Add
onCarry(member.id)} title="Carry over pending tasks from the previous day"
className="px-2.5 py-1.5 rounded-lg text-sm border bg-white" style={{ borderColor: "#DDE0DC", color: "#5B6470" }}>
↻
)}
);
}
/* ═══════════ 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 (
<>
{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) => (
canTick && onToggle(member.id, t.id)} disabled={!canTick}
aria-label={t.done ? "Mark not done" : "Mark done"}
className="w-5 h-5 rounded border-2 flex items-center justify-center text-[11px] shrink-0 transition-all"
style={{
borderColor: t.done ? "#3DA164" : "#C4C9C4",
background: t.done ? "#3DA164" : "#fff",
color: "#fff",
cursor: canTick ? "pointer" : "default",
}}>
{t.done ? "✓" : ""}
onRename(member.id, t.id, v)}
lockHint="Assigned by the admin"
className={t.done ? "line-through" : ""}
style={{ color: t.done ? "#9AA1A9" : "#16191D" }} />
{canAssign && (
onDelete(member.id, t.id)} aria-label="Delete task"
className="del-btn opacity-0 transition-opacity w-5 h-5 text-xs rounded hover:bg-red-50 shrink-0"
style={{ color: "#E8553F" }}>✕
)}
))}
{!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" }} />
Add
)}
);
}
/* ═══════════ 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]) => (
setScope(s)}
className="px-3 py-1.5 rounded-lg text-xs font-medium"
style={scope === s ? { background: "#16191D", color: "#fff" } : { background: "transparent", color: "#5B6470" }}>
{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" }} />
{savingName ? "Saving…" : "Save"}
) : (
{me.name}
{ setNameVal(me.name || ""); setEditingName(true); }}
className="text-xs font-normal underline" style={{ color: "#5B6470" }}>edit 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}
);
})}
)}
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]) => (
setScope(s)}
className="px-3 py-1.5 rounded-lg text-xs font-medium"
style={scope === s ? { background: "#16191D", color: "#fff" } : { background: "transparent", color: "#5B6470" }}>
{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 (
setOpenId(isOpen ? null : m.id)}
className="w-full text-left p-4 flex items-center gap-3 hover:bg-black/[0.02]">
{i + 1}
{initials(m.name)}
{m.name}
{m.done} done
{m.inProgress > 0 && {m.inProgress} in progress }
{m.pending > 0 && {m.pending} pending }
{m.adminAssigned > 0 && (
★ {m.adminDone}/{m.adminAssigned} from admin
)}
{m.assigned && m.assigned.total > 0 && (
◆ {m.assigned.done}/{m.assigned.total} {scope}
)}
= 80 ? "#3DA164" : m.pct >= 40 ? "#F2A93B" : "#E8553F" }} />
{m.total ? `${m.pct}%` : "—"}
{isOpen ? "▾" : "▸"}
{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 (
setTab(id)}
className="flex-1 px-3 py-2.5 text-xs font-semibold flex items-center justify-center gap-1.5 transition-colors"
style={{
background: on ? "#fff" : "transparent",
color: on ? "#16191D" : "#5B6470",
borderBottom: on ? "2px solid #16191D" : "2px solid transparent",
}}>
{label}
{counts[id] > 0 && (
{counts[id]}
)}
);
})}
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."}
{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 (
toggle(s.id)} disabled={!isAdmin}
aria-label={s.done ? "Mark not done" : "Mark done"}
title={isAdmin ? "" : "Only an admin can tick these off"}
className="mt-0.5 w-5 h-5 rounded border-2 flex items-center justify-center text-[11px] shrink-0"
style={{ borderColor: s.done ? "#3DA164" : "#C4C9C4", background: s.done ? "#3DA164" : "#fff", color: "#fff", cursor: isAdmin ? "pointer" : "default" }}>
{s.done ? "✓" : ""}
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 && (
del(s.id)} aria-label="Delete request"
className="del-btn opacity-0 transition-opacity w-5 h-5 text-xs rounded hover:bg-red-50 shrink-0" style={{ color: "#E8553F" }}>✕
)}
);
})}
);
}
/* ═══════════ 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."}
{list === null ? (
Loading…
) : !items.length ? (
No suggestions yet.
) : items.map((s) => {
const canDelete = isAdmin || String(s.byId) === String(me.id);
return (
isAdmin && toggle(s.id)} disabled={!isAdmin}
aria-label={s.done ? "Mark not done" : "Mark done"}
title={isAdmin ? "" : "Only the developer ticks these"}
className="mt-0.5 w-5 h-5 rounded border-2 flex items-center justify-center text-[11px] shrink-0"
style={{ borderColor: s.done ? "#3DA164" : "#C4C9C4", background: s.done ? "#3DA164" : "#fff", color: "#fff", cursor: isAdmin ? "pointer" : "default" }}>
{s.done ? "✓" : ""}
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 && (
del(s.id)} aria-label="Delete suggestion"
className="del-btn opacity-0 transition-opacity w-5 h-5 text-xs rounded hover:bg-red-50 shrink-0" style={{ color: "#E8553F" }}>✕
)}
);
})}
);
}
/* ═══════════ 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 (
🔔
{unread > 0 && (
{unread > 9 ? "9+" : unread}
)}
{open && (
Notifications
{unread > 0 ? `${unread} unread` : "You're all caught up"}
{items.length > 0 && Clear all }
setOpen(false)} aria-label="Close" className="w-6 h-6 rounded-full hover:bg-black/5" style={{ color: "#5B6470" }}>✕
{!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( );
}