// ============ TaskQuest — data & logic ============ // Clean slate: no demo users, no seeded tasks. ROLES/PROJECTS are populated at // runtime — from Supabase in LIVE mode, or in-memory in OFFLINE preview mode. const ROLES = {}; // id -> profile (filled by applyProfiles) const CONSULTANTS = []; // profile ids const MANAGERS = []; // profile ids // palette for newly-created users / projects const PALETTE = ["#5E6AD2", "#16a394", "#e0902b", "#eb5757", "#8b7cf6", "#4ea7fc", "#d4569b", "#2bb673", "#e8633a", "#0d9488"]; // Configurable scoring system (editable by System Admin) const CONFIG = { onTimePoints: 10, latePoints: 0, bonusTiers: [5, 10, 20], }; function roleLabelFor(type) { return type === "pm" ? "Project Manager" : type === "dm" ? "Delivery Manager" : type === "admin" ? "System Admin" : "Consultant"; } function makeInitials(name) { const parts = name.trim().split(/\s+/); if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); } // Starter projects (also seeded in supabase/schema.sql so LIVE & OFFLINE match). const PROJECTS = { alpha: { key: "alpha", name: "Project Alpha", color: "#5E6AD2" }, beta: { key: "beta", name: "Project Beta", color: "#16a394" }, gamma: { key: "gamma", name: "Project Gamma", color: "#e0902b" }, }; let _projSeq = 0; function addProject({ name, color }) { _projSeq += 1; const key = "p" + _projSeq + "_" + name.trim().toLowerCase().replace(/[^a-z]/g, "").slice(0, 6); PROJECTS[key] = { key, name: name.trim(), color: color || PALETTE[_projSeq % PALETTE.length] }; return key; } const COLUMNS = [ { key: "happy", name: "Happy Path", color: "#4cb782", desc: "On track" }, { key: "unhappy", name: "Needs attention", color: "#f2c94c", desc: "Date change pending" }, { key: "onhold", name: "On Hold", color: "#878b94", desc: "Blocked / paused" }, { key: "done", name: "Done", color: "#8b7cf6", desc: "Completed" }, ]; // Task sub-categories — the kind of work a task represents. const SUBCATEGORIES = [ { key: "code", name: "Code", color: "#5E6AD2" }, { key: "ui", name: "UI", color: "#4ea7fc" }, { key: "design", name: "Design", color: "#8b7cf6" }, { key: "docs", name: "Documentation", color: "#16a394" }, { key: "qa", name: "QA / Testing", color: "#e0902b" }, { key: "review", name: "Review", color: "#cf6ad9" }, { key: "other", name: "Other", color: "#878b94" }, ]; // ============ Deliverables ============ // A Deliverable is a high-level unit of work the Delivery Manager creates and // assigns to a Project Manager (a backlog). The PM breaks it down into tasks; // the deliverable's status & progress are derived from those child tasks. const DELIVERABLES = []; // filled at runtime (seed in OFFLINE mode) const DLV_STATUS = { assigned: { key: "assigned", name: "Assigned", color: "#4ea7fc", desc: "Awaiting breakdown" }, inprogress: { key: "inprogress", name: "In Progress", color: "#f2c94c", desc: "Being delivered" }, delivered: { key: "delivered", name: "Delivered", color: "#4cb782", desc: "Ready for sign-off" }, accepted: { key: "accepted", name: "Accepted", color: "#8b7cf6", desc: "Signed off by Delivery" }, }; const DLV_STATUS_ORDER = ["assigned", "inprogress", "delivered", "accepted"]; const PRIORITY = { high: { key: "high", name: "High", color: "#eb5757" }, med: { key: "med", name: "Medium", color: "#e0902b" }, low: { key: "low", name: "Low", color: "#878b94" }, }; let _did = 100; function mkDeliverable(o) { _did += 1; return Object.assign({ id: "DLV-" + _did, priority: "med", desc: "", accepted: false, // DM sign-off once all child tasks are done comments: [], }, o); } function deliverableTasks(d, tasks) { return tasks.filter(t => t.deliverable === d.id); } // Derive a deliverable's live status + progress from its child tasks. function computeDeliverable(d, tasks) { const kids = deliverableTasks(d, tasks); const total = kids.length; const done = kids.filter(t => t.status === "done").length; const overdue = kids.some(t => t.status !== "done" && daysUntil(t.due) < 0); let status; if (d.accepted) status = "accepted"; else if (total === 0) status = "assigned"; else if (done === total) status = "delivered"; else status = "inprogress"; const pct = total ? Math.round((done / total) * 100) : 0; return { kids, total, done, pct, overdue, status, meta: DLV_STATUS[status] }; } function applyDeliverables(list) { DELIVERABLES.length = 0; (list || []).forEach(d => DELIVERABLES.push(d)); } // date helpers — "today" is the real current date (so past-date blocking is // correct and demo seed offsets always render relative to today). Normalized to // local midnight for clean date-only comparisons. const TODAY = (function () { const n = new Date(); return new Date(n.getFullYear(), n.getMonth(), n.getDate()); })(); function isoFromOffset(days) { const d = new Date(TODAY); d.setDate(d.getDate() + days); return d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0"); } function parseISO(s) { const [y, m, d] = s.split("-").map(Number); return new Date(y, m - 1, d); } function fmtDate(iso) { if (!iso) return "—"; const d = parseISO(iso); return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); } function fmtDateLong(iso) { if (!iso) return "—"; return parseISO(iso).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", year: "numeric" }); } function daysUntil(iso) { return Math.round((parseISO(iso) - TODAY) / 86400000); } function relDue(iso) { const n = daysUntil(iso); if (n === 0) return "Due today"; if (n === 1) return "Due tomorrow"; if (n === -1) return "1 day overdue"; if (n < 0) return `${-n} days overdue`; if (n <= 7) return `Due in ${n} days`; return "Due " + fmtDate(iso); } let _tid = 100; function mkTask(o) { _tid += 1; return Object.assign({ id: "TQ-" + _tid + "-" + Math.random().toString(36).slice(2, 6), comments: [], deliverable: null, // parent Deliverable id (set when a PM breaks one down) description: "", // longer task description (optional) subcategory: null, // SUBCATEGORIES key (code/ui/design/docs/qa/review/other) request: null, // { newDate, reason, comments, status:'pending'|'approved'|'rejected', approvedDate } completion: null, // { onTime:bool, status:'pending'|'approved', comments } }, o); } // ============ Scoring & leaderboard ============ function computeScores(tasks, bonus) { const cfg = window.TQ ? window.TQ.CONFIG : CONFIG; const stats = {}; CONSULTANTS.forEach(c => { stats[c] = { key: c, base: 0, bonus: 0, total: 0, assigned: 0, ontime: 0, completed: 0, perf: 0 }; }); tasks.forEach(t => { if (!stats[t.assignee]) return; const s = stats[t.assignee]; // On Hold work is blocked/paused — it doesn't count toward the assigned workload. // "Assigned" = Happy Path + Needs Attention + Done. if (t.status === "onhold") return; s.assigned += 1; if (t.status === "done") { s.completed += 1; if (t.completion && t.completion.status === "approved") { if (t.completion.onTime) { s.ontime += 1; s.base += cfg.onTimePoints; } else { s.base += cfg.latePoints; } } } }); bonus.forEach(b => { if (stats[b.to]) stats[b.to].bonus += b.pts; }); Object.values(stats).forEach(s => { s.total = s.base + s.bonus; // Average points per completed task: (on-time/late points + bonus) ÷ tasks completed. s.avg = s.completed ? s.total / s.completed : 0; // Performance = tasks completed (Done) ÷ tasks assigned (Happy Path + Needs Attention + Done). s.perf = s.assigned ? Math.round((s.completed / s.assigned) * 100) : 0; }); return stats; } function leaderboard(tasks, bonus, projectKey) { const scoped = projectKey === "all" ? tasks : tasks.filter(t => t.project === projectKey); const scopedBonus = projectKey === "all" ? bonus : bonus.filter(b => b.project === projectKey); const stats = computeScores(scoped, scopedBonus); const arr = Object.values(stats); arr.sort((a, b) => b.avg - a.avg || b.perf - a.perf || a.key.localeCompare(b.key)); arr.forEach((s, i) => { s.rank = i + 1; }); return arr; } // ============ Live-data adapters ============ // Mutate the SAME ROLES/PROJECTS/CONFIG objects in place so references that // components destructured at load time stay valid. function applyProfiles(map) { Object.keys(ROLES).forEach(k => delete ROLES[k]); Object.assign(ROLES, map); CONSULTANTS.length = 0; MANAGERS.length = 0; Object.values(map).forEach(r => { if (r.type === "consultant") CONSULTANTS.push(r.key); else if (r.type === "pm" || r.type === "dm") MANAGERS.push(r.key); }); } function applyProjects(map) { Object.keys(PROJECTS).forEach(k => delete PROJECTS[k]); Object.assign(PROJECTS, map); } function applyConfig(cfg) { if (cfg) Object.assign(CONFIG, cfg); } // A notification is visible to a user if it targets their id, their role type, // or the 'type:' token the database stores. function notifVisible(n, meId, meType) { const f = n.forRoles || []; return f.indexOf(meId) >= 0 || f.indexOf(meType) >= 0 || f.indexOf("type:" + meType) >= 0; } // A profile's project membership as an array of valid project keys. Prefers the // multi-value `projects` array, falls back to the legacy single `project` field, // and drops any keys that no longer exist. function projectsOf(r) { if (!r) return []; const list = Array.isArray(r.projects) && r.projects.length ? r.projects : (r.project ? [r.project] : []); return list.filter(k => PROJECTS[k]); } window.TQ = { ROLES, CONSULTANTS, MANAGERS, PROJECTS, COLUMNS, SUBCATEGORIES, TODAY, CONFIG, PALETTE, DELIVERABLES, DLV_STATUS, DLV_STATUS_ORDER, PRIORITY, isoFromOffset, parseISO, fmtDate, fmtDateLong, daysUntil, relDue, mkTask, mkDeliverable, deliverableTasks, computeDeliverable, computeScores, leaderboard, addProject, roleLabelFor, makeInitials, projectsOf, applyProfiles, applyProjects, applyConfig, applyDeliverables, notifVisible, };