<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Project Management Board</title>
<!-- Load Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Load Lucide Icons - FIX: Changed to standard script tag to ensure global 'lucide' is defined -->
<script src="https://cdn.jsdelivr.net/npm/@lucide/web@latest/lib/umd/lucide.js"></script>
<style>
/* Custom styles for the table grid structure */
.board-grid-header, .board-grid-row {
display: grid;
grid-template-columns: 32px 300px 100px 140px 160px 100px 100px 100px 100px 32px; /* Defines the columns */
min-width: 1064px; /* Ensure content doesn't wrap */
}
.board-grid-row {
transition: background-color 0.15s;
}
.board-grid-row:hover {
background-color: #f5f5f5;
}
.status-pill {
display: flex;
align-items: center;
justify-content: center;
padding: 2px 8px;
border-radius: 9999px;
font-weight: 500;
font-size: 0.8rem;
border: 1px solid transparent;
cursor: pointer;
user-select: none;
transition: all 0.2s;
}
/* Style for the date input which is hidden by default */
.date-input-overlay {
position: absolute;
z-index: 20;
background: white;
padding: 8px;
border: 1px solid #ccc;
border-radius: 6px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.timeline-bar {
height: 10px;
border-radius: 9999px;
}
/* Custom scrollbar for wide tables */
.table-container::-webkit-scrollbar {
height: 8px;
}
.table-container::-webkit-scrollbar-thumb {
background-color: #d1d5db; /* gray-300 */
border-radius: 4px;
}
.table-container::-webkit-scrollbar-track {
background-color: #f3f4f6; /* gray-100 */
}
</style>
</head>
<body class="bg-gray-50 min-h-screen font-sans">
<!-- Header / Top Bar -->
<header class="flex items-center justify-between p-4 bg-white border-b border-gray-200 shadow-sm">
<h1 class="text-2xl font-bold text-gray-800 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-6 h-6 mr-3 text-blue-600"><rect width="18" height="18" x="3" y="3" rx="2"></rect><path d="M7 3v18"></path><path d="M3 7h18"></path><path d="M3 17h18"></path><path d="M17 3v18"></path></svg>
Zella Games Project Board
</h1>
<div class="flex items-center space-x-4 text-sm text-gray-600">
<button class="flex items-center hover:text-blue-600">
<i data-lucide="bell" class="w-4 h-4 mr-1"></i> Notifications
</button>
<span id="user-display" class="flex items-center bg-blue-500 text-white rounded-full p-1 text-xs font-medium cursor-pointer">
<i data-lucide="user" class="w-4 h-4"></i>
</span>
</div>
</header>
<!-- Project Tabs/Menu -->
<div class="flex p-4 bg-white border-b border-gray-200 sticky top-0 z-10">
<span class="text-sm font-semibold text-gray-800 px-3 py-1 border-b-2 border-blue-600">Main Table</span>
<span class="text-sm text-gray-500 px-3 py-1 cursor-pointer hover:text-blue-600">Gantt</span>
<span class="text-sm text-gray-500 px-3 py-1 cursor-pointer hover:text-blue-600">Tasks Assigned To Me</span>
</div>
<!-- Controls Bar -->
<div class="p-4 bg-white shadow-md flex items-center space-x-4 border-b">
<button id="add-task-btn" class="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-blue-700 transition duration-150">
<i data-lucide="plus" class="w-4 h-4 inline-block mr-1"></i> New Task
</button>
<input type="text" placeholder="Search tasks..." class="border border-gray-300 p-2 rounded-lg text-sm w-64 focus:ring-blue-500 focus:border-blue-500">
<span class="text-sm text-gray-500">Filters:</span>
<span class="text-sm text-gray-800 font-medium cursor-pointer flex items-center hover:bg-gray-100 p-1 rounded-md">
<i data-lucide="person-standing" class="w-4 h-4 mr-1 text-gray-500"></i> Owner
</span>
<span class="text-sm text-gray-800 font-medium cursor-pointer flex items-center hover:bg-gray-100 p-1 rounded-md">
<i data-lucide="calendar" class="w-4 h-4 mr-1 text-gray-500"></i> Date
</span>
</div>
<!-- Main Board Container -->
<div class="p-4 table-container overflow-x-auto">
<div id="board-header" class="board-grid-header text-xs font-semibold text-gray-500 border-b border-gray-300 py-2 sticky top-24 bg-gray-50 z-10">
<div class="px-2"></div>
<div class="px-2 text-left">TASK</div>
<div class="px-2 text-center">OWNER</div>
<div class="px-2 text-center">STATUS</div>
<div class="px-2 text-center">TIMELINE</div>
<div class="px-2 text-center">DURATION</div>
<div class="px-2 text-center">PLANNED EFFORT (h)</div>
<div class="px-2 text-center">EFFORT SPENT (h)</div>
<div class="px-2 text-center">COMPLETION DATE</div>
<div class="px-2"></div>
</div>
<div id="tasks-container" class="space-y-4">
<!-- Tasks will be rendered here by JavaScript -->
</div>
<!-- Pop-up for Status change (hidden by default) -->
<div id="status-popup" class="hidden absolute bg-white shadow-xl rounded-lg p-2 z-30 border border-gray-200">
<div class="text-xs font-semibold text-gray-500 mb-1 px-2">Change Status</div>
<div id="status-options" class="space-y-1">
<!-- Status options will be populated here -->
</div>
</div>
<!-- Pop-up for Timeline change (hidden by default) -->
<div id="timeline-popup" class="hidden absolute bg-white shadow-xl rounded-lg p-3 z-30 border border-gray-200">
<div class="text-xs font-semibold text-gray-500 mb-2">Set Timeline</div>
<div class="space-y-2 text-sm">
<label class="block">Start Date:</label>
<input type="date" id="popup-start-date" class="border p-1 rounded text-gray-700 w-full">
<label class="block">End Date:</label>
<input type="date" id="popup-end-date" class="border p-1 rounded text-gray-700 w-full">
<button id="save-timeline-btn" class="mt-3 bg-green-500 text-white px-3 py-1 rounded w-full hover:bg-green-600 transition">Save</button>
</div>
</div>
</div>
<!-- Firebase Imports and Script -->
<script type="module">
import { initializeApp } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-app.js";
import { getAuth, signInAnonymously, signInWithCustomToken, onAuthStateChanged } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-auth.js";
import { getFirestore, doc, getDoc, setDoc, onSnapshot, collection, query, orderBy, addDoc, updateDoc, where, getDocs, deleteDoc, runTransaction } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js";
import { setLogLevel } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js";
// IMPORTANT: Global variables for Firebase context provided by the canvas environment
const appId = typeof __app_id !== 'undefined' ? __app_id : 'default-app-id';
const firebaseConfig = typeof __firebase_config !== 'undefined' ? JSON.parse(__firebase_config) : { /* Mock Config */ };
const initialAuthToken = typeof __initial_auth_token !== 'undefined' ? __initial_auth_token : null;
setLogLevel('Debug');
// --- App State & Constants ---
let app;
let db;
let auth;
let userId = null;
let userName = 'Guest User';
let tasks = [];
let isAuthReady = false;
const STATUS_OPTIONS = ['Working On It', 'Done', 'Stuck', 'Future Step'];
const STATUS_COLORS = {
'Working On It': 'bg-yellow-100 text-yellow-800 border-yellow-300 hover:bg-yellow-200',
'Done': 'bg-teal-100 text-teal-800 border-teal-300 hover:bg-teal-200',
'Stuck': 'bg-red-100 text-red-800 border-red-300 hover:bg-red-200',
'Future Step': 'bg-blue-100 text-blue-800 border-blue-300 hover:bg-blue-200',
};
const SECTIONS = ['Pre-Production', 'Production'];
const FIRESTORE_PATH = `/artifacts/${appId}/public/data/tasks`;
// Utility to format date for input fields
const formatDate = (dateString) => {
if (!dateString) return '';
return new Date(dateString).toISOString().split('T')[0];
};
// Utility to calculate duration in days
const calculateDuration = (start, end) => {
if (!start || !end) return 0;
const diffTime = Math.abs(new Date(end) - new Date(start));
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays;
};
// --- Firebase Initialization and Auth ---
const initializeFirebase = async () => {
try {
app = initializeApp(firebaseConfig);
db = getFirestore(app);
auth = getAuth(app);
onAuthStateChanged(auth, async (user) => {
if (user) {
userId = user.uid;
// For simplicity, generate a short display name from UID
userName = `User-${user.uid.substring(0, 4)}`;
document.getElementById('user-display').innerHTML = `<i data-lucide="user" class="w-4 h-4"></i> ${userName}`;
// Since lucide is loaded globally, we can call createIcons here
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
if (!isAuthReady) {
console.log("Authentication successful. Starting real-time listener.");
isAuthReady = true;
setupRealtimeListener();
}
} else if (initialAuthToken) {
await signInWithCustomToken(auth, initialAuthToken);
} else {
// Fallback to anonymous sign-in if no token is available
await signInAnonymously(auth);
}
});
} catch (e) {
console.error("Error initializing Firebase:", e);
document.getElementById('tasks-container').innerHTML = '<p class="text-red-600">Failed to connect to the database. Check console for details.</p>';
}
};
// --- Data Handling ---
const setupRealtimeListener = () => {
if (!db || !isAuthReady) return;
const q = query(collection(db, FIRESTORE_PATH));
onSnapshot(q, (snapshot) => {
tasks = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
tasks.sort((a, b) => (SECTIONS.indexOf(a.section) - SECTIONS.indexOf(b.section)) || a.order - b.order);
console.log(`Fetched ${tasks.length} tasks.`);
renderBoard();
}, (error) => {
console.error("Error listening to tasks:", error);
});
};
const updateTaskField = async (taskId, field, value) => {
if (!db || !userId) return console.error("Database not ready or user not authenticated.");
try {
const taskRef = doc(db, FIRESTORE_PATH, taskId);
await updateDoc(taskRef, { [field]: value });
console.log(`Task ${taskId} updated: ${field} = ${value}`);
} catch (e) {
console.error("Error updating task:", e);
}
};
const addTask = async (isSubtask = false, parentId = null, section = SECTIONS[0]) => {
if (!db || !userId) return console.error("Database not ready or user not authenticated.");
const maxOrder = tasks
.filter(t => t.section === section && t.parentId === parentId)
.reduce((max, t) => Math.max(max, t.order || 0), 0);
const newTask = {
title: isSubtask ? 'New Subtask' : 'New Task',
section: section,
isSubtask: isSubtask,
parentId: parentId,
ownerId: userId,
ownerName: userName,
status: 'Working On It',
startDate: formatDate(new Date()),
endDate: formatDate(new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)), // 7 days later
durationDays: 7,
plannedEffortHours: 8,
effortSpentHours: 0,
comments: [],
order: maxOrder + 1,
};
try {
await addDoc(collection(db, FIRESTORE_PATH), newTask);
} catch (e) {
console.error("Error adding task:", e);
}
};
const updateTaskTimeline = async (taskId, start, end) => {
if (!db || !userId) return console.error("Database not ready or user not authenticated.");
const durationDays = calculateDuration(start, end);
try {
const taskRef = doc(db, FIRESTORE_PATH, taskId);
await updateDoc(taskRef, {
startDate: start,
endDate: end,
durationDays: durationDays
});
// Close popup after successful update (handled in global listeners)
} catch (e) {
console.error("Error updating task timeline:", e);
}
};
// --- UI Rendering ---
const getStatusPillHtml = (status, taskId) => {
const colorClass = STATUS_COLORS[status] || 'bg-gray-100 text-gray-800 border-gray-300';
return `<div data-task-id="${taskId}" class="status-pill ${colorClass} update-status">${status}</div>`;
};
const getTimelineBarHtml = (startDate, endDate) => {
if (!startDate || !endDate) return '';
const today = new Date();
const start = new Date(startDate);
const end = new Date(endDate);
const totalDuration = end.getTime() - start.getTime();
const elapsed = today.getTime() - start.getTime();
let percentage = 0;
if (elapsed > 0) {
percentage = Math.min(100, Math.max(0, (elapsed / totalDuration) * 100));
}
// Simple color logic based on status
const barColor = 'bg-blue-500';
return `
<div class="w-full h-2 bg-gray-200 rounded-full relative">
<div class="timeline-bar absolute top-0 left-0 ${barColor}" style="width: ${percentage}%;"></div>
</div>
`;
};
const renderTaskRow = (task) => {
const isSubtask = task.isSubtask;
const indentation = isSubtask ? 'ml-4' : 'ml-0';
const rowClasses = isSubtask ? 'bg-white border-b border-gray-100 text-sm' : 'bg-white border-b border-gray-200 text-base font-medium';
const taskTitleClasses = isSubtask ? 'text-gray-600' : 'text-gray-800';
const durationText = task.durationDays > 0 ? `${task.durationDays} days` : '0 days';
const timelineDates = task.startDate && task.endDate ? `${formatDate(task.startDate)} - ${formatDate(task.endDate)}` : '-';
return `
<div data-task-id="${task.id}" class="board-grid-row py-2 items-center ${rowClasses}">
<div class="px-2">
${isSubtask ? '' : `<i data-lucide="plus-circle" class="w-4 h-4 text-gray-400 hover:text-blue-500 cursor-pointer add-subtask-btn" data-parent-id="${task.id}" data-section="${task.section}"></i>`}
</div>
<div class="px-2 flex items-center ${indentation} update-title" data-task-id="${task.id}" contenteditable="true">
<span class="${taskTitleClasses} whitespace-nowrap">${task.title}</span>
</div>
<div class="px-2 text-center text-xs text-gray-700 font-normal">
<div class="flex items-center justify-center space-x-1">
<i data-lucide="user" class="w-4 h-4 text-gray-500"></i>
<span>${task.ownerName}</span>
</div>
</div>
<div class="px-2 text-center">
${getStatusPillHtml(task.status, task.id)}
</div>
<div class="px-2 text-center timeline-cell" data-task-id="${task.id}">
<div class="text-xs text-gray-600 font-normal timeline-display cursor-pointer" data-task-id="${task.id}" data-start="${task.startDate}" data-end="${task.endDate}">
${timelineDates === '-' ? '-' : timelineDates}
${getTimelineBarHtml(task.startDate, task.endDate)}
</div>
</div>
<div class="px-2 text-center text-xs text-gray-600 font-normal">${durationText}</div>
<div class="px-2 text-center text-xs text-gray-600 font-normal">${task.plannedEffortHours}</div>
<div class="px-2 text-center text-xs text-gray-600 font-normal">${task.effortSpentHours}</div>
<div class="px-2 text-center text-xs text-gray-600 font-normal">-</div>
<div class="px-2 text-center">
<i data-lucide="message-square" class="w-4 h-4 text-gray-400 hover:text-blue-500 cursor-pointer"></i>
</div>
</div>
`;
};
const renderBoard = () => {
const container = document.getElementById('tasks-container');
container.innerHTML = ''; // Clear existing content
let html = '';
SECTIONS.forEach(section => {
const sectionTasks = tasks.filter(t => t.section === section && !t.parentId);
if (sectionTasks.length === 0) return;
// Section Header
html += `
<div class="flex items-center space-x-2 text-lg font-semibold text-gray-800 mt-6 mb-2">
<i data-lucide="chevrons-down-up" class="w-5 h-5 text-gray-400"></i>
<span>${section}</span>
</div>
`;
sectionTasks.forEach(task => {
html += renderTaskRow(task);
// Render subtasks immediately after parent
const subtasks = tasks.filter(t => t.parentId === task.id);
subtasks.forEach(subtask => {
html += renderTaskRow(subtask);
});
});
// Add "Add task" row for the section
html += `
<button class="flex items-center text-sm text-gray-500 hover:text-blue-600 mt-2 px-2 py-1" onclick="addTask(false, null, '${section}')">
<i data-lucide="plus" class="w-4 h-4 mr-1"></i> Add task
</button>
`;
});
container.innerHTML = html;
// Since lucide is loaded globally, we can call createIcons here
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
};
// --- Event Listeners and Popups ---
let activeTaskId = null;
let activeStatusElement = null;
let activeTimelineElement = null;
const statusPopup = document.getElementById('status-popup');
const timelinePopup = document.getElementById('timeline-popup');
// Close popups when clicking outside
document.addEventListener('click', (e) => {
if (activeStatusElement && !statusPopup.contains(e.target) && e.target !== activeStatusElement) {
statusPopup.classList.add('hidden');
activeStatusElement = null;
activeTaskId = null;
}
if (activeTimelineElement && !timelinePopup.contains(e.target) && e.target.closest('.timeline-cell') !== activeTimelineElement) {
timelinePopup.classList.add('hidden');
activeTimelineElement = null;
activeTaskId = null;
}
});
// Delegate click events for dynamic elements
document.addEventListener('click', (e) => {
// 1. Status Pill Click
const statusPill = e.target.closest('.update-status');
if (statusPill) {
e.stopPropagation();
activeTaskId = statusPill.getAttribute('data-task-id');
activeStatusElement = statusPill;
// Position the popup
const rect = statusPill.getBoundingClientRect();
statusPopup.style.top = `${rect.bottom + 5}px`;
statusPopup.style.left = `${rect.left}px`;
// Populate and show status options
const optionsContainer = document.getElementById('status-options');
optionsContainer.innerHTML = STATUS_OPTIONS.map(status => {
const colorClass = STATUS_COLORS[status].replace('hover:bg-', 'hover:bg-');
return `<div class="status-pill ${colorClass} w-full" data-status="${status}">${status}</div>`;
}).join('');
statusPopup.classList.remove('hidden');
}
// 2. Add Task Button
if (e.target.closest('#add-task-btn')) {
addTask();
}
// 3. Add Subtask Button
const addSubtaskBtn = e.target.closest('.add-subtask-btn');
if (addSubtaskBtn) {
const parentId = addSubtaskBtn.getAttribute('data-parent-id');
const section = addSubtaskBtn.getAttribute('data-section');
addTask(true, parentId, section);
}
// 4. Timeline Click
const timelineCell = e.target.closest('.timeline-cell');
if (timelineCell) {
e.stopPropagation();
activeTaskId = timelineCell.getAttribute('data-task-id');
activeTimelineElement = timelineCell;
const task = tasks.find(t => t.id === activeTaskId);
// Position the popup
const rect = timelineCell.getBoundingClientRect();
timelinePopup.style.top = `${rect.bottom + 5}px`;
timelinePopup.style.left = `${rect.left}px`;
// Populate dates
document.getElementById('popup-start-date').value = formatDate(task.startDate);
document.getElementById('popup-end-date').value = formatDate(task.endDate);
timelinePopup.classList.remove('hidden');
}
});
// Delegate click for status options
document.getElementById('status-options').addEventListener('click', (e) => {
const selectedStatus = e.target.closest('.status-pill');
if (selectedStatus && activeTaskId) {
const status = selectedStatus.getAttribute('data-status');
updateTaskField(activeTaskId, 'status', status);
statusPopup.classList.add('hidden');
}
});
// Save Timeline button handler
document.getElementById('save-timeline-btn').addEventListener('click', () => {
const start = document.getElementById('popup-start-date').value;
const end = document.getElementById('popup-end-date').value;
if (activeTaskId && start && end) {
updateTaskTimeline(activeTaskId, start, end);
timelinePopup.classList.add('hidden');
} else {
console.warn("Missing dates or active task ID.");
}
});
// Delegate blur event for editable title
document.addEventListener('blur', async (e) => {
const titleElement = e.target.closest('.update-title');
if (titleElement) {
const taskId = titleElement.getAttribute('data-task-id');
const newTitle = titleElement.innerText.trim();
const currentTask = tasks.find(t => t.id === taskId);
if (currentTask && currentTask.title !== newTitle) {
if (newTitle === "") {
// Prevent empty title, revert or set default
titleElement.innerText = currentTask.title;
return;
}
await updateTaskField(taskId, 'title', newTitle);
}
}
}, true); // Use capture phase to catch blur on contenteditable elements
// --- Application Start ---
window.onload = initializeFirebase;
</script>
</body>
</html>