Zenith Gantt - Modern Project Management

ram alsafadi

ram alsafadi

<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <!-- Basic Meta --> <title>Zenith Gantt - Modern Project Management</title> <meta name="description" content="An interactive, modern Gantt chart application for seamless project management, featuring drag-and-drop tasks, real-time updates, and a responsive design."> <meta name="keywords" content="gantt chart, project management, task management, interactive, javascript, responsive, timeline"> <meta name="theme-color" content="#4a90e2"> <!-- PWA Theme Color --> <link rel="icon" href="favicon.ico" type="image/x-icon"> <!-- Favicon --> <link rel="manifest" href="manifest.json"> <!-- PWA Manifest --> <!-- Open Graph / Facebook --> <meta property="og:type" content="website"> <meta property="og:url" content="[YOUR_APPLICATION_URL]"> <!-- Replace with actual URL --> <meta property="og:title" content="Zenith Gantt - Modern Project Management"> <meta property="og:description" content="Interactive Gantt chart for seamless project planning and tracking."> <meta property="og:image" content="[YOUR_PREVIEW_IMAGE_URL]"> <!-- Replace with URL to a preview image --> <!-- Twitter --> <meta property="twitter:card" content="summary_large_image"> <meta property="twitter:url" content="[YOUR_APPLICATION_URL]"> <!-- Replace with actual URL --> <meta property="twitter:title" content="Zenith Gantt - Modern Project Management"> <meta property="twitter:description" content="Interactive Gantt chart for seamless project planning and tracking."> <meta property="twitter:image" content="[YOUR_PREVIEW_IMAGE_URL]"> <!-- Replace with URL to a preview image --> <!-- Fonts --> <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap" rel="stylesheet"> <!-- Inline Critical CSS (Conceptual Placeholder - True inlining needs build tools) --> <style> /* --- Critical Base & Layout (Simulated) --- */ :root { /* Light Mode Vars (Default) */ --bg-gradient-start: #eef2f7; --bg-gradient-end: #ffffff; --bg-main: #ffffff; --bg-secondary: #f4f7fa; --bg-tertiary: #e9ecef; --bg-backdrop: rgba(50, 50, 90, 0.6); --bg-glass: rgba(255, 255, 255, 0.7); --text-primary: #2d3748; --text-secondary: #4a5568; --text-tertiary: #718096; --text-on-primary-bg: #ffffff; --text-link: var(--primary-color); --border-color: #e1e8f0; --border-color-strong: #ced4da; --primary-color: #4a90e2; --primary-hover: #357abd; --primary-focus-ring: rgba(74, 144, 226, 0.3); --accent-green: #48bb78; --accent-green-bg: #e6fcf5; --accent-yellow: #ecc94b; --accent-yellow-bg: #fffbeb; --accent-red: #f56565; --accent-red-bg: #fff0f4; --accent-gray: #a0aec0; --accent-gray-hover: #8d9cae; --shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.04); --shadow-md: 0 4px 10px rgba(0, 0, 0, 0.06); --shadow-lg: 0 10px 20px rgba(0, 0, 0, 0.08); /* Dimensions & Layout */ --header-height: 65px; --row-height: 48px; /* FIX 2: Increase min day width for readability */ --day-min-width: 55px; --left-pane-width: 280px; --gutter-sm: 8px; --gutter-md: 15px; --gutter-lg: 25px; --radius-sm: 4px; --radius-md: 8px; --radius-lg: 12px; /* Transitions */ --transition-fast: 0.15s ease-out; --transition-base: 0.25s ease-out; /* Variable for dynamic day width calculation */ --day-width: var(--day-min-width); /* Default fallback */ } @media (prefers-color-scheme: dark) { :root { /* Dark Mode Vars */ --bg-gradient-start: #2d3748; --bg-gradient-end: #1a202c; --bg-main: #1f2937; /* Slightly lighter than gradient end */ --bg-secondary: #2d3748; --bg-tertiary: #374151; --bg-backdrop: rgba(10, 10, 30, 0.7); --bg-glass: rgba(45, 55, 72, 0.7); --text-primary: #e2e8f0; --text-secondary: #cbd5e0; --text-tertiary: #a0aec0; --text-on-primary-bg: #ffffff; /* Keep white text on primary buttons */ --text-link: #63b3ed; /* Lighter blue link */ --border-color: #4a5568; --border-color-strong: #5a6678; --primary-color: #63b3ed; /* Lighter blue for primary */ --primary-hover: #4299e1; --primary-focus-ring: rgba(99, 179, 237, 0.4); --accent-green: #68d391; --accent-green-bg: #2f4d3c; --accent-yellow: #faf089; --accent-yellow-bg: #5a4f1a; --accent-red: #fc8181; --accent-red-bg: #5e2e2e; --accent-gray: #718096; --accent-gray-hover: #a0aec0; --shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.15); --shadow-md: 0 5px 12px rgba(0, 0, 0, 0.2); --shadow-lg: 0 12px 25px rgba(0, 0, 0, 0.25); } /* Dark mode specific adjustments for elements if needed */ .timeline-header .weekend { background-color: #2a3341; } .task-bar { color: var(--text-primary); } /* Ensure text readable */ .task-assignee { background-color: rgba(255,255,255,0.1); color: var(--text-tertiary); } .keyboard-shortcuts kbd { background-color: var(--bg-tertiary); border-color: var(--border-color-strong); box-shadow: inset 0 -1px 0 var(--border-color); color: var(--text-primary); } } *, *::before, *::after { box-sizing: border-box; } html { font-size: 16px; /* Base font size */ scroll-behavior: smooth; color-scheme: light dark; /* Indicate support for color schemes */ } body { font-family: 'Poppins', sans-serif; margin: 0; padding: 0; background: linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-end) 100%); color: var(--text-primary); line-height: 1.6; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; min-height: 100vh; display: flex; flex-direction: column; } main { flex-grow: 1; /* Ensure footer stays at bottom */ } /* --- Utility Classes --- */ .visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } /* Focus visible styling */ :focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; box-shadow: 0 0 0 3px var(--primary-focus-ring); border-radius: var(--radius-sm); } /* Reset for elements that manage their own focus style */ .task-bar:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, .modal button:focus-visible, .controls button:focus-visible, .context-menu div:focus-visible { outline: none; box-shadow: none; /* Remove default if element has specific style */ } input:focus-visible, select:focus-visible, textarea:focus-visible { border-color: var(--primary-color); box-shadow: 0 0 0 3px var(--primary-focus-ring); /* Specific input focus */ } /* --- Fix 1: Control Desktop/Mobile view visibility using body classes --- */ body.is-desktop #mobileView { display: none; } body.is-mobile .desktop-container { display: none; } /* Default state (mobile view hidden unless body has is-mobile) */ #mobileView { display: none; } body.is-mobile #mobileView { display: flex; /* Use flex as defined in mobile styles */ flex-direction: column; height: calc(100vh - var(--header-height)); padding: 0; background-color: var(--bg-secondary); } </style> <!-- Non-Critical CSS (Loaded normally in single-file setup) --> <style> /* --- Layout Containers --- */ .site-header { background-color: var(--bg-main); padding: 0 var(--gutter-lg); /* Use variable */ height: var(--header-height); box-shadow: var(--shadow-sm); display: flex; justify-content: space-between; align-items: center; position: sticky; top: 0; z-index: 100; } .site-header h1 { /* Fluid Typography Example */ font-size: clamp(1.2rem, 1.1rem + 0.5vw, 1.5rem); margin: 0; color: var(--primary-color); font-weight: 600; } .desktop-container { /* Used above tablet breakpoint */ max-width: 1600px; margin: var(--gutter-lg) auto; padding: 0 var(--gutter-lg); } .site-footer { text-align: center; margin-top: calc(var(--gutter-lg) * 1.5); padding: var(--gutter-md); font-size: clamp(0.75rem, 0.7rem + 0.2vw, 0.85rem); /* Fluid small text */ color: var(--text-tertiary); border-top: 1px solid var(--border-color); background-color: var(--bg-secondary); } /* --- Components: Controls --- */ .controls { display: flex; gap: var(--gutter-md); margin-bottom: var(--gutter-lg); justify-content: space-between; align-items: center; flex-wrap: wrap; } .controls button { padding: var(--gutter-sm) var(--gutter-md); background-color: var(--primary-color); color: var(--text-on-primary-bg); border: none; border-radius: var(--radius-md); cursor: pointer; font-size: 0.9rem; font-weight: 500; transition: background-color var(--transition-fast), transform var(--transition-fast), box-shadow var(--transition-base); box-shadow: var(--shadow-sm); } .controls button:hover { background-color: var(--primary-hover); transform: translateY(-1px); box-shadow: var(--shadow-md); } .controls button:active { transform: translateY(0px); box-shadow: var(--shadow-sm); } .controls button:focus-visible { /* Specific focus style for buttons */ outline: 2px solid var(--primary-hover); outline-offset: 2px; } .search-box { position: relative; /* For clear button positioning */ display: flex; align-items: center; } .search-box input { padding: calc(var(--gutter-sm) + 2px) var(--gutter-md); padding-right: calc(var(--gutter-md) + 20px); /* Space for clear button */ border: 1px solid var(--border-color); border-radius: var(--radius-md); font-size: 0.9rem; width: 250px; background-color: var(--bg-main); color: var(--text-primary); transition: border-color var(--transition-base), box-shadow var(--transition-base); } .search-box input:focus { outline: none; /* Use focus-visible style */ } .search-box .clear-search { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); background: none; border: none; font-size: 1.3rem; color: var(--text-tertiary); cursor: pointer; padding: 0 5px; display: none; /* Hidden by default, shown by JS */ } .search-box input:not(:placeholder-shown) + .clear-search { display: block; /* Show when input has value */ } /* --- Components: Gantt Chart --- */ .gantt-chart-container { background-color: var(--bg-main); border-radius: var(--radius-lg); box-shadow: var(--shadow-md); overflow: hidden; /* Important */ } .gantt-chart { display: grid; /* Use CSS variable set by JS for left pane width */ grid-template-columns: var(--left-pane-width) 1fr; /* Removed overflow-x: auto here - let right-pane handle it */ position: relative; } .left-pane { border-right: 1px solid var(--border-color); background-color: var(--bg-secondary); padding-top: var(--row-height); /* Align first stage with timeline header */ position: sticky; /* Keep task names visible when scrolling horizontally */ left: 0; z-index: 3; /* Above timeline content */ /* Ensure left pane height matches right pane content height if needed */ height: max-content; /* Adjust height based on content */ } .right-pane { position: relative; overflow-x: auto; /* Allow horizontal scrolling ONLY for the right pane */ width: 100%; /* Ensure it takes the grid column space */ } .stage-row { display: contents; } /* Allow children to participate in grid */ .stage-name { width: 100%; border-top: 2px solid rgb(14, 0, 94) !important; border-bottom: 2px solid rgb(14, 0, 94) !important; padding: 0 var(--gutter-md); /* Padding handled by height/line-height */ height: var(--row-height); display: flex; align-items: center; font-weight: 600; font-size: 1rem; background-color: var(--bg-secondary); border-bottom: 1px solid var(--border-color); cursor: pointer; transition: background-color var(--transition-base); color: var(--text-primary); /* border-top: 1px solid var(--border-color); Removed, handled by context */ /* Removed sticky from here - left-pane handles stickiness */ } .stage-name:first-of-type { border-top: none; /* No top border for the very first stage name */ } .stage-name:hover { background-color: var(--bg-tertiary); } .stage-name .toggle-icon { margin-right: var(--gutter-sm); width: 14px; height: 14px; transition: transform var(--transition-base); fill: var(--primary-color); } .stage-name.collapsed .toggle-icon { transform: rotate(-90deg); } .task-name { padding: 0 var(--gutter-md); font-size: 0.9rem; border-bottom: 1px solid var(--border-color); height: var(--row-height); display: flex; align-items: center; transition: background-color var(--transition-base); background-color: var(--bg-main); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .task-name:last-child { border-bottom: none; } .task-name:hover { background-color: var(--bg-secondary); } .task-name.highlight { background-color: var(--primary-focus-ring); font-weight: 500; } .task-name.focused-row { background-color: var(--primary-focus-ring); } .task-name.no-tasks-message { font-style: italic; color: var(--text-tertiary); background-color: transparent; } .timeline-header { display: grid; /* Grid columns set by JS: grid-template-columns: repeat(var(--total-days), minmax(var(--day-min-width), 1fr)); */ background-color: var(--bg-secondary); border-bottom: 1px solid var(--border-color-strong); /* Stronger border */ font-size: 0.8rem; text-align: center; color: var(--text-tertiary); position: sticky; top: 0; z-index: 5; /* Above everything else in right pane */ height: var(--row-height); width: max-content; /* Allow header to be as wide as its content */ min-width: 100%; /* Ensure it stretches at least full pane width */ } .timeline-header div { padding: 0 5px; /* Adjust padding as needed */ height: var(--row-height); line-height: var(--row-height); /* Vertically center text */ border-right: 1px solid var(--border-color); font-weight: 500; white-space: nowrap; overflow: hidden; /* Prevent overflow if content too wide */ text-overflow: ellipsis; /* Ellipsis if too wide */ /* Ensure background stretches */ background-color: inherit; /* Inherit from .timeline-header */ } .timeline-header div:last-child { border-right: none; } .timeline-header .weekend { background-color: rgba(0,0,0,0.02); } /* Subtle weekend */ .today-marker { position: absolute; width: 2px; background-color: var(--accent-red); top: 0; /* Starts below header */ bottom: 0; z-index: 2; /* Above grid lines, below task bars */ opacity: 0.8; pointer-events: none; /* Allow clicking through marker */ } .today-marker::before { /* Extend line into header */ content: ''; position: absolute; width: 2px; background-color: var(--accent-red); top: calc(-1 * var(--row-height)); /* Go up by header height */ height: var(--row-height); left: 0; } /* This container holds all task rows/bars in the right pane */ #taskContainers { position: relative; /* Needed for absolute positioning of task bars and today marker */ width: max-content; /* Ensure it stretches to the width of the timeline content */ min-width: 100%; /* Ensure it's at least as wide as the pane */ } .task-container { height: var(--row-height); border-bottom: 1px solid var(--border-color); position: relative; /* Vertical Grid Lines (Subtle) */ /* FIX 4: Use --day-width variable correctly for background grid */ background-image: linear-gradient(to right, var(--border-color) 1px, transparent 1px); background-size: var(--day-width) 100%; /* Use the calculated day width */ /* Ensures container spans full width even if empty */ min-width: 100%; } /* Last container in the entire set */ #taskContainers > .task-container:last-child { border-bottom: none; } .task-container.stage-header-row { /* Style the empty row for stage headers */ background-image: none; /* No grid lines */ border-bottom: 1px solid var(--border-color); /* Keep bottom border */ background-color: var(--bg-secondary); } .task-container.no-tasks-row { background-image: none; border-bottom: none; min-height: var(--row-height); height: var(--row-height); } /* Style for no tasks row */ .task-bar { position: absolute; height: calc(var(--row-height) - 16px); /* Slightly smaller than row */ top: var(--gutter-sm); /* Center vertically */ border-radius: var(--radius-md); border-left: 5px solid; /* Priority indicator */ display: flex; align-items: center; padding: 0 var(--gutter-sm); font-size: 0.8rem; font-weight: 500; cursor: move; transition: opacity var(--transition-base), box-shadow var(--transition-base), transform var(--transition-fast); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; box-shadow: var(--shadow-sm); color: var(--text-secondary); will-change: transform, opacity, left, width; /* Optimize rendering */ z-index: 3; /* Default z-index above grid lines/today marker */ } /* Priority Styles */ .task-bar.low { border-left-color: var(--accent-green); background-color: var(--accent-green-bg); color: var(--text-primary); } .task-bar.medium { border-left-color: var(--accent-yellow); background-color: var(--accent-yellow-bg); color: var(--text-primary); } .task-bar.high { border-left-color: var(--accent-red); background-color: var(--accent-red-bg); color: var(--text-primary); } .task-bar:hover { box-shadow: var(--shadow-md); transform: translateY(-1px); z-index: 4; /* Above other bars */ } .task-bar.dragging { opacity: 0.75; z-index: 6; /* Highest */ box-shadow: var(--shadow-lg); transform: translateY(-2px) scale(1.01); /* Slight lift effect */ cursor: grabbing; } .task-bar.focused { /* Specific focus style for task bar */ outline: 2px solid var(--primary-color); outline-offset: 1px; box-shadow: var(--shadow-md); z-index: 5; /* Above non-focused bars */ } .task-assignee { margin-left: var(--gutter-sm); font-size: 0.7rem; background-color: rgba(0,0,0,0.05); padding: 2px 6px; border-radius: 10px; font-weight: 400; color: var(--text-tertiary); flex-shrink: 0; /* Prevent shrinking */ } .resize-handle { position: absolute; width: 10px; /* Wider handle area */ height: 100%; top: 0; cursor: ew-resize; z-index: 1; /* Above task bar content, below bar itself? Check if resizing works */ } .resize-handle::before { /* Visual indicator (optional) */ content: ''; position: absolute; top: 25%; height: 50%; width: 2px; background-color: rgba(0,0,0,0.1); border-radius: 1px; } .resize-handle.left { left: -3px; } .resize-handle.right { right: -3px; } .resize-handle.left::before { left: 3px; } .resize-handle.right::before { right: 3px; } .resize-handle:hover::before { background-color: var(--primary-color); } /* --- Components: Task Details Section --- */ #taskDetailsSection { margin-top: var(--gutter-lg); padding: var(--gutter-lg); background-color: var(--bg-glass); /* Glassmorphism */ backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); /* Safari */ border-radius: var(--radius-lg); box-shadow: var(--shadow-md); border: 1px solid var(--border-color); /* Subtle border for glass */ opacity: 0; max-height: 0; overflow: hidden; transition: opacity 0.4s ease, max-height 0.4s ease, padding 0.4s ease, margin-top 0.4s ease, border 0.4s ease; will-change: opacity, max-height; } #taskDetailsSection.visible { opacity: 1; max-height: 500px; /* Adjust as needed */ padding: var(--gutter-lg); margin-top: var(--gutter-lg); border-color: var(--border-color); } #taskDetailsSection h3 { margin: 0 0 var(--gutter-md) 0; font-size: clamp(1.1rem, 1rem + 0.4vw, 1.3rem); font-weight: 600; color: var(--primary-color); } #taskDetailsSection p { margin: 5px 0 var(--gutter-md) 0; color: var(--text-secondary); font-size: 0.9rem; } #taskDetailsSection strong { color: var(--text-primary); font-weight: 500; margin-right: var(--gutter-sm); } #taskDetailsSection .details-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--gutter-md); } .priority-low { color: var(--accent-green); font-weight: 600; } .priority-medium { color: var(--accent-yellow); font-weight: 600; } .priority-high { color: var(--accent-red); font-weight: 600; } /* --- Components: Modal --- */ @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes scaleUp { from { transform: scale(0.95); } to { transform: scale(1); } } .modal { display: none; /* Initially hidden */ position: fixed; inset: 0; /* top, left, bottom, right = 0 */ background-color: var(--bg-backdrop); justify-content: center; align-items: center; z-index: 1000; opacity: 0; transition: opacity var(--transition-base); padding: var(--gutter-md); /* Padding for smaller screens */ will-change: opacity; } .modal.show { display: flex; opacity: 1; animation: fadeIn var(--transition-base) forwards; } .modal-content { background-color: var(--bg-main); padding: var(--gutter-lg); border-radius: var(--radius-lg); width: 100%; /* Take width from grid/flex context */ max-width: 500px; box-shadow: var(--shadow-lg); transition: transform var(--transition-base); will-change: transform; animation: scaleUp var(--transition-base) forwards; max-height: 90vh; /* Prevent overflow */ overflow-y: auto; /* Allow scroll if content exceeds height */ } .modal-content h2 { font-size: clamp(1.2rem, 1.1rem + 0.4vw, 1.4rem); margin: 0 0 var(--gutter-lg) 0; color: var(--primary-color); font-weight: 600; } .modal-content label { display: block; font-size: 0.85rem; margin: var(--gutter-md) 0 var(--gutter-sm) 0; color: var(--text-tertiary); font-weight: 500; } .modal-content input, .modal-content select, .modal-content textarea { width: 100%; padding: var(--gutter-sm) var(--gutter-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); font-size: 0.9rem; margin-bottom: var(--gutter-md); box-sizing: border-box; background-color: var(--bg-main); color: var(--text-primary); transition: border-color var(--transition-base), box-shadow var(--transition-base); } .modal-content input:focus, .modal-content select:focus, .modal-content textarea:focus { outline: none; /* Use focus-visible style */ } .modal-content textarea { min-height: 90px; resize: vertical; } .modal-content .form-grid { /* Grid for inputs */ display: grid; grid-template-columns: 1fr 1fr; gap: var(--gutter-md); } .modal-content .button-group { margin-top: var(--gutter-lg); display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; /* Allow wrapping */ gap: var(--gutter-sm); } .modal-content .button-group div { /* Group for save/cancel */ display: flex; gap: var(--gutter-sm); } .modal-content button { padding: var(--gutter-sm) var(--gutter-md); border: none; border-radius: var(--radius-md); cursor: pointer; font-size: 0.9rem; font-weight: 500; transition: background-color var(--transition-base), transform var(--transition-fast), box-shadow var(--transition-base); box-shadow: var(--shadow-sm); } .modal-content button:hover { transform: translateY(-1px); box-shadow: var(--shadow-md); } .modal-content button:active { transform: translateY(0px); box-shadow: var(--shadow-sm); } .modal-content button:focus-visible { /* Specific focus for modal buttons */ outline: 2px solid var(--primary-color); outline-offset: 2px; } .modal-content .save-btn { background-color: var(--accent-green); color: #fff; } .modal-content .save-btn:hover { filter: brightness(1.1); } .modal-content .cancel-btn { background-color: var(--accent-gray); color: #fff; } .modal-content .cancel-btn:hover { background-color: var(--accent-gray-hover); } .modal-content .delete-btn { background-color: var(--accent-red); color: #fff; } .modal-content .delete-btn:hover { filter: brightness(1.1); } /* --- Components: Context Menu --- */ .context-menu { position: absolute; background-color: var(--bg-glass); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); border: 1px solid var(--border-color); border-radius: var(--radius-md); box-shadow: var(--shadow-lg); z-index: 1001; /* Above modal overlay */ display: none; padding: var(--gutter-sm) 0; min-width: 160px; } .context-menu div[role="menuitem"] { padding: var(--gutter-sm) var(--gutter-md); font-size: 0.9rem; cursor: pointer; transition: background-color var(--transition-base); color: var(--text-primary); background-color: transparent; /* Ensure background is clean */ } .context-menu div[role="menuitem"]:hover, .context-menu div[role="menuitem"]:focus-visible { background-color: var(--primary-focus-ring); color: var(--primary-color); outline: none; /* Handled by background */ } /* --- Components: Keyboard Shortcuts --- */ .keyboard-shortcuts { position: fixed; bottom: var(--gutter-md); right: var(--gutter-md); background-color: var(--bg-glass); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); border: 1px solid var(--border-color); border-radius: var(--radius-md); box-shadow: var(--shadow-md); padding: var(--gutter-md) var(--gutter-lg); font-size: 0.8rem; display: none; z-index: 50; max-width: 280px; } .keyboard-shortcuts h3 { margin: 0 0 var(--gutter-sm) 0; font-size: 0.9rem; font-weight: 600; color: var(--text-secondary); } .keyboard-shortcuts ul { margin: 0; padding-left: var(--gutter-md); } .keyboard-shortcuts li { margin-bottom: var(--gutter-sm); color: var(--text-tertiary); } .keyboard-shortcuts kbd { background-color: var(--bg-tertiary); border: 1px solid var(--border-color-strong); border-radius: var(--radius-sm); padding: 2px 5px; font-size: 0.75rem; font-family: 'Consolas', monospace; margin: 0 2px; box-shadow: inset 0 -1px 0 var(--border-color); color: var(--text-secondary); } /* --- Components: Users Online --- */ .users-online { display: flex; /* Simple horizontal list */ align-items: center; gap: calc(var(--gutter-sm) / 2); } .users-online h3 { display: none; } /* Remove title */ .user-avatar { width: 32px; height: 32px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-weight: 600; color: #fff; font-size: 0.8rem; border: 2px solid var(--bg-main); /* Overlap effect */ box-shadow: var(--shadow-sm); } .user-avatar:not(:first-child) { margin-left: -10px; /* Overlap avatars */ } /* --- Components: Notification --- */ @keyframes slideInUp { from { transform: translateY(100px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } @keyframes fadeOut { from { opacity: 1; } to { opacity: 0; transform: translateY(10px); } /* Slight move down on fade */ } .notification { position: fixed; bottom: var(--gutter-lg); left: var(--gutter-lg); color: #fff; padding: var(--gutter-sm) var(--gutter-lg); border-radius: var(--radius-md); box-shadow: var(--shadow-md); font-size: 0.9rem; font-weight: 500; transform: translateY(100px); /* Start off-screen */ opacity: 0; transition: transform 0.4s cubic-bezier(0.25, 0.8, 0.25, 1), opacity 0.4s ease; z-index: 1002; /* Above context menu */ will-change: transform, opacity; } /* --- Components: Quick Guide Box --- */ .quick-guide { background-color: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: var(--gutter-md) var(--gutter-lg); margin-bottom: var(--gutter-lg); position: relative; box-shadow: var(--shadow-sm); font-size: 0.85rem; transition: opacity 0.3s ease, max-height 0.3s ease, margin 0.3s ease, padding 0.3s ease; max-height: 500px; /* Allow space for content */ opacity: 1; overflow: hidden; } .quick-guide.hidden { opacity: 0; max-height: 0; padding-top: 0; padding-bottom: 0; margin-bottom: 0; border-width: 0; /* Wait until transition ends to fully hide for accessibility */ /* display: none; -- applied via JS after transition */ } .quick-guide h3 { margin: 0 0 var(--gutter-md) 0; font-size: 1rem; font-weight: 600; color: var(--primary-color); } .quick-guide ul { margin: 0; padding-left: var(--gutter-md); list-style: disc; color: var(--text-secondary); } .quick-guide li { margin-bottom: var(--gutter-sm); } .quick-guide strong { color: var(--text-primary); font-weight: 500; } /* Use existing kbd styles */ .quick-guide kbd { background-color: var(--bg-tertiary); border: 1px solid var(--border-color-strong); border-radius: var(--radius-sm); padding: 1px 4px; font-size: 0.75rem; font-family: 'Consolas', monospace; margin: 0 2px; box-shadow: inset 0 -1px 0 var(--border-color); color: var(--text-secondary); } /* Dark mode kbd refinement if needed */ @media (prefers-color-scheme: dark) { .quick-guide kbd { background-color: var(--bg-main); color: var(--text-primary); box-shadow: inset 0 -1px 0 var(--border-color-strong); border-color: var(--border-color); } } .quick-guide .close-guide { position: absolute; top: var(--gutter-sm); right: var(--gutter-sm); background: none; border: none; font-size: 1.5rem; line-height: 1; color: var(--text-tertiary); cursor: pointer; padding: 0 5px; opacity: 0.7; transition: opacity var(--transition-fast); } .quick-guide .close-guide:hover { opacity: 1; color: var(--accent-red); } .notification.show { /* Use animation for entrance */ animation: slideInUp 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) forwards; } .notification.hide { /* Use transition for exit */ opacity: 0; transform: translateY(10px); } /* --- Responsive: Tablet (Portrait) --- */ @media (min-width: 769px) and (max-width: 1024px) { :root { --left-pane-width: 220px; /* Narrower left pane */ } .desktop-container { padding: 0 var(--gutter-md); } .site-header { padding: 0 var(--gutter-md); } .controls .search-box input { width: 200px; } .task-bar { font-size: 0.75rem; } .task-assignee { display: none; } /* Hide assignee on smaller bars */ } /* --- Responsive: Mobile (Portrait & Landscape) --- */ @media (max-width: 768px) { :root { --header-height: 55px; /* Smaller mobile header */ --row-height: 44px; /* Smaller mobile rows if needed */ } html { font-size: 15px; } /* Slightly smaller base font */ /* Desktop container and related elements are hidden via body class */ /* #taskDetailsSection { display: none; } */ /* Already part of desktop container */ .users-online { display: none; } .keyboard-shortcuts { display: none; } .site-footer { margin-top: 0; } /* Mobile View Container styling (moved to base styles with body.is-mobile) */ /* Mobile Header */ .mobile-header { display: flex; justify-content: space-between; align-items: center; padding: 0 var(--gutter-md); height: var(--header-height); /* Match header height */ background-color: var(--bg-main); border-bottom: 1px solid var(--border-color); flex-shrink: 0; box-shadow: var(--shadow-sm); } .mobile-header .title { font-weight: 600; color: var(--text-primary); } .mobile-header .actions button { padding: var(--gutter-sm); font-size: 1rem; /* Icon size */ line-height: 1; margin-left: var(--gutter-sm); background-color: transparent; color: var(--primary-color); border: none; border-radius: var(--radius-sm); box-shadow: none; } .mobile-header .actions button:hover { background-color: var(--bg-tertiary); } .mobile-header .actions .add-icon, .mobile-header .actions .filter-icon { width: 20px; height: 20px; fill: currentColor; } /* Mobile Date Navigation */ .mobile-date-nav { display: flex; justify-content: space-between; align-items: center; padding: var(--gutter-sm) var(--gutter-md); background-color: var(--bg-main); border-bottom: 1px solid var(--border-color); flex-shrink: 0; box-shadow: var(--shadow-sm); } .mobile-date-nav button { background: none; border: none; color: var(--primary-color); font-size: 1.5rem; line-height: 1; cursor: pointer; padding: 5px; } .mobile-date-nav #mobileDateDisplay { font-size: 0.95rem; font-weight: 600; color: var(--text-primary); text-align: center; flex-grow: 1; } .mobile-date-nav .today-btn { font-size: 0.8rem; font-weight: 500; border: 1px solid var(--border-color); padding: 4px 8px; border-radius: var(--radius-sm); } /* Mobile Task List */ .mobile-task-list { flex-grow: 1; overflow-y: auto; padding: var(--gutter-md); } .mobile-task-card { background-color: var(--bg-main); border-radius: var(--radius-md); margin-bottom: var(--gutter-md); padding: var(--gutter-md); box-shadow: var(--shadow-sm); border-left: 5px solid; /* Priority indicator */ transition: box-shadow var(--transition-base); } .mobile-task-card:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 1px; } .mobile-task-card.low { border-left-color: var(--accent-green); } .mobile-task-card.medium { border-left-color: var(--accent-yellow); } .mobile-task-card.high { border-left-color: var(--accent-red); } .mobile-task-card h4 { margin: 0 0 5px 0; font-size: 1rem; font-weight: 600; color: var(--text-primary); } .mobile-task-card p { margin: 3px 0; font-size: 0.85rem; color: var(--text-tertiary); } .mobile-task-card .assignee { font-style: italic; } .mobile-task-card .dates { font-weight: 500; color: var(--text-secondary); } .mobile-task-list .no-tasks { text-align: center; color: var(--text-tertiary); padding: 30px; font-style: italic; } /* Mobile Modal Adjustments */ .modal-content { max-width: 95%; /* Almost full width */ padding: var(--gutter-md); } .modal-content h2 { font-size: 1.2rem; } .modal-content label { font-size: 0.8rem; margin-top: var(--gutter-sm); } .modal-content input, .modal-content select, .modal-content textarea { padding: var(--gutter-sm); font-size: 0.9rem; margin-bottom: var(--gutter-sm); } .modal-content .form-grid { grid-template-columns: 1fr; } /* Stack grid items */ .modal-content .button-group { flex-direction: column; align-items: stretch; gap: var(--gutter-sm); } .modal-content .button-group div { justify-content: flex-end; } .modal-content .delete-btn { margin-top: var(--gutter-sm); width: 100%; order: 3; } /* Make delete last */ .modal-content .button-group div:nth-of-type(1) { order: 2; } /* Save/Cancel group second */ } </style> </head> <body> <!-- Classes is-desktop or is-mobile will be added by JS --> <!-- Site Header --> <header class="site-header"> <h1>Zenith Gantt</h1> <div class="users-online" id="usersOnline"> <!-- User avatars added by JS --> </div> </header> <!-- Main Content Area --> <main> <!-- Desktop View Container --> <div class="desktop-container"> <h2 id="controls-heading" class="visually-hidden">Project Controls</h2> <!-- Quick Guide Box --> <div id="quickGuideBox" class="quick-guide" role="complementary" aria-labelledby="quickGuideHeading"> <button class="close-guide" onclick="hideQuickGuide()" aria-label="Dismiss Quick Guide">×</button> <h3 id="quickGuideHeading">Quick Guide</h3> <ul> <li><strong>Add Task:</strong> Click "Add Task" button or press <kbd>N</kbd>.</li> <li><strong>Edit/View:</strong> Double-click a task/row, press <kbd>Enter</kbd> on selected, or use context menu (<kbd>E</kbd> key works too).</li> <li><strong>Move:</strong> Drag task bars horizontally. Nudge selected task with <kbd>←</kbd>/<kbd>→</kbd>.</li> <li><strong>Resize:</strong> Drag the left/right edges of task bars.</li> <li><strong>Select:</strong> Click task name or bar. Use <kbd>↑</kbd>/<kbd>↓</kbd> to navigate, <kbd>Space</kbd> to select/deselect.</li> <li><strong>Stages:</strong> Click stage names or use <kbd>C</kbd> to collapse/expand all.</li> <li><strong>Shortcuts:</strong> Click "Shortcuts (?)" or press <kbd>?</kbd>.</li> <li><strong>Mobile:</strong> View uses a daily list format.</li> </ul> </div> <!-- End Quick Guide Box --> <h2 id="controls-heading" class="visually-hidden">Project Controls</h2> <section class="controls" aria-labelledby="controls-heading"> <section class="controls" aria-labelledby="controls-heading"> <div> <button onclick="showModal()" tabindex="0">Add Task</button> <button onclick="toggleShowAll()" tabindex="0" id="toggleShowAllBtn">Collapse All</button> <!-- Text updated by JS --> <button onclick="toggleKeyboardShortcuts()" title="Show Keyboard Shortcuts (?)">Shortcuts <span aria-hidden="true">(?)</span></button> </div> <div class="search-box"> <label for="searchInput" class="visually-hidden">Search Tasks</label> <input type="search" id="searchInput" placeholder="Search tasks..." oninput="debouncedSearchTasks()"> <button type="button" class="clear-search" onclick="clearSearch()" aria-label="Clear search" tabindex="-1">×</button> </div> </section> <h2 id="gantt-heading" class="visually-hidden">Gantt Chart</h2> <section class="gantt-chart-container" aria-labelledby="gantt-heading"> <div class="gantt-chart" id="ganttChart"> <div class="left-pane" id="leftPane"> <!-- Task names and stages added by JS --> </div> <div class="right-pane" id="rightPane"> <div class="timeline-header" id="timelineHeader"> <!-- Timeline headers added by JS --> </div> <div id="taskContainers"> <!-- Task containers and bars added by JS --> </div> <!-- Today marker added by JS relative to rightPane --> </div> </div> </section> <h3 id="details-heading" class="visually-hidden">Selected Task Details</h3> <section id="taskDetailsSection" aria-labelledby="details-heading" aria-live="polite"> <!-- Task details loaded here by JS --> <p>Select a task to see its details.</p> </section> </div> <!-- Mobile View Container --> <!-- FIX 1: This container is now hidden by default and shown via body.is-mobile --> <div id="mobileView" role="region" aria-label="Mobile Task View"> <div class="mobile-header"> <span class="title">Daily Tasks</span> <div class="actions"> <button onclick="alert('Filter functionality not implemented yet.')" title="Filter Tasks" aria-label="Filter Tasks"> <svg class="filter-icon" viewBox="0 0 20 20" aria-hidden="true"><path d="M3 3a1 1 0 0 1 1-1h12a1 1 0 0 1 .768 1.64L12 10.764V17a1 1 0 0 1-.38.8l-3 2A1 1 0 0 1 7 19v-8.236l-4.768-6.124A1 1 0 0 1 3 3z"/></svg> </button> <button onclick="showModal()" title="Add New Task" aria-label="Add New Task"> <svg class="add-icon" viewBox="0 0 20 20" aria-hidden="true"><path fill-rule="evenodd" d="M10 3a1 1 0 0 1 1 1v5h5a1 1 0 1 1 0 2h-5v5a1 1 0 1 1-2 0v-5H4a1 1 0 1 1 0-2h5V4a1 1 0 0 1 1-1z" clip-rule="evenodd"/></svg> </button> </div> </div> <nav class="mobile-date-nav" aria-label="Date Navigation"> <button onclick="navigateMobileDate(-1)" title="Previous Day" aria-label="Previous Day"><</button> <span id="mobileDateDisplay" role="status">Date</span> <button onclick="navigateMobileDate(1)" title="Next Day" aria-label="Next Day">></button> <button onclick="navigateMobileDate(0)" title="Go To Today" class="today-btn">Today</button> </nav> <div class="mobile-task-list" id="mobileTaskList"> <!-- Mobile task cards added by JS --> </div> </div> </main> <!-- Footer --> <footer class="site-footer"> © <span id="currentYear"></span> Zenith Gantt. Built with HTML, CSS & Vanilla JS. </footer> <!-- Modal Dialog --> <div class="modal" id="taskModal" role="dialog" aria-modal="true" aria-labelledby="modalTitle" style="display: none;"> <div class="modal-content"> <h2 id="modalTitle">Add Task</h2> <label for="taskName">Task Name:</label> <input type="text" id="taskName" required> <label for="taskDescription">Description:</label> <textarea id="taskDescription"></textarea> <div class="form-grid"> <div> <label for="taskPriority">Priority:</label> <select id="taskPriority"> <option value="low">Low</option> <option value="medium">Medium</option> <option value="high">High</option> </select> </div> <div> <label for="taskStage">Stage:</label> <select id="taskStage"> <!-- Options added by JS --> </select> </div> </div> <label for="taskAssignee">Assignee:</label> <select id="taskAssignee"> <!-- Options added by JS --> </select> <div class="form-grid"> <div> <label for="startDate">Start Date:</label> <input type="date" id="startDate" required> </div> <div> <label for="endDate">End Date:</label> <input type="date" id="endDate" required> </div> </div> <div class="button-group"> <button type="button" class="delete-btn" id="deleteTaskBtn" onclick="deleteTaskFromModal()" tabindex="0" style="display: none;">Delete Task</button> <div> <!-- Group Save and Cancel --> <button type="button" class="cancel-btn" onclick="hideModal()" tabindex="0">Cancel</button> <button type="button" class="save-btn" onclick="saveTask()" tabindex="0">Save Task</button> </div> </div> </div> </div> <!-- Context Menu --> <div class="context-menu" id="contextMenu" role="menu" style="display: none;"> <div onclick="editTask()" tabindex="-1" role="menuitem">Edit Task</div> <div onclick="duplicateTask()" tabindex="-1" role="menuitem">Duplicate Task</div> <div onclick="deleteTask()" tabindex="-1" role="menuitem">Delete Task</div> </div> <!-- Keyboard Shortcuts Popup --> <div class="keyboard-shortcuts" id="keyboardShortcuts" role="complementary" style="display: none;" tabindex="-1"> <h3>Keyboard Shortcuts</h3> <ul> <li><kbd>N</kbd> - Add new task</li> <li><kbd>E</kbd> / <kbd>Enter</kbd> - Edit selected</li> <li><kbd>Delete</kbd> - Delete selected</li> <li><kbd>↑</kbd> <kbd>↓</kbd> - Navigate tasks</li> <li><kbd>←</kbd> <kbd>→</kbd> - Nudge task dates</li> <li><kbd>Space</kbd> - Select/deselect task</li> <li><kbd>C</kbd> - Collapse/Expand All Stages</li> <li><kbd>Esc</kbd> - Close modal/menu/shortcuts, Deselect Task</li> <li><kbd>?</kbd> - Show/hide shortcuts</li> </ul> </div> <!-- Notification Area --> <div class="notification" id="notification" role="status" aria-live="polite"></div> <!-- ============================================= --> <!-- SCRIPT SECTION --> <!-- ============================================= --> <script> // Wrap in IIFE to avoid global scope pollution (function() { 'use strict'; // --- Constants & State --- const STAGES = ['Planning', 'Development', 'Testing', 'Deployment', 'Maintenance']; let tasks = [ { id: 1, name: 'Project Planning', description: 'Define project scope, goals, and deliverables. Create initial timeline.', priority: 'high', stage: 'Planning', assignee: 'John', startDate: '2025-04-14', endDate: '2025-04-20' }, { id: 2, name: 'UI Design Mockups', description: 'Create low-fidelity and high-fidelity mockups for the user interface.', priority: 'medium', stage: 'Planning', assignee: 'Alice', startDate: '2025-04-17', endDate: '2025-04-23' }, { id: 3, name: 'API Development (Core)', description: 'Implement essential backend API endpoints for core functionality.', priority: 'high', stage: 'Development', assignee: 'Bob', startDate: '2025-04-21', endDate: '2025-04-30' }, { id: 4, name: 'Frontend Component Library', description: 'Develop reusable UI components based on designs.', priority: 'medium', stage: 'Development', assignee: 'Emma', startDate: '2025-04-24', endDate: '2025-05-05' }, { id: 5, name: 'Backend Unit Testing', description: 'Write and execute unit tests for API endpoints.', priority: 'low', stage: 'Testing', assignee: 'Bob', startDate: '2025-05-01', endDate: '2025-05-04' }, { id: 6, name: 'Frontend Integration', description: 'Integrate frontend components with API data.', priority: 'medium', stage: 'Development', assignee: 'Emma', startDate: '2025-05-06', endDate: '2025-05-15' }, { id: 7, name: 'User Acceptance Testing', description: 'Conduct UAT with stakeholders.', priority: 'high', stage: 'Testing', assignee: 'Alice', startDate: '2025-05-16', endDate: '2025-05-20' }, { id: 8, name: 'Deployment to Staging', description: 'Deploy the application to a staging environment.', priority: 'medium', stage: 'Deployment', assignee: 'John', startDate: '2025-05-21', endDate: '2025-05-22' }, ]; let users = [ { id: 1, name: 'John', color: '#4a90e2' }, // Blue { id: 2, name: 'Alice', color: '#50e3c2' }, // Teal { id: 3, name: 'Bob', color: '#f5a623' }, // Orange { id: 4, name: 'Emma', color: '#bd10e0' }, // Purple { id: 5, name: 'You', color: '#e14a6e', isCurrentUser: true } // Pink/Red ]; let draggingTask = null; let resizingTask = null; let resizeSide = null; let dragOffsetX = 0; let selectedTaskId = null; let stageCollapsed = {}; let taskBarElements = {}; // Cache for task bar DOM elements let showAllTasks = true; // FIX 3: Start collapsed by default is correct state var let currentContextTask = null; let mobileCurrentDate = new Date(); let notificationTimer = null; let searchDebounceTimer = null; // DOM Element Refs (Cache common elements) const taskModal = document.getElementById('taskModal'); const contextMenu = document.getElementById('contextMenu'); const keyboardShortcuts = document.getElementById('keyboardShortcuts'); const notificationElement = document.getElementById('notification'); const searchInput = document.getElementById('searchInput'); const leftPane = document.getElementById('leftPane'); const rightPane = document.getElementById('rightPane'); const timelineHeader = document.getElementById('timelineHeader'); const taskContainers = document.getElementById('taskContainers'); const taskDetailsSection = document.getElementById('taskDetailsSection'); const mobileTaskList = document.getElementById('mobileTaskList'); const ganttChartElement = document.getElementById('ganttChart'); // For setting CSS vars // --- Initialization --- document.addEventListener('DOMContentLoaded', () => { initializeTasks(); initializeCollapsedState(); // FIX 3: Set initial collapsed state populateSelectOptions(); setupEventListeners(); initializeUsers(); setupMobileView(); render(); // Initial render decides desktop/mobile updateToggleButtonText(); // FIX 3: Set initial button text setCopyrightYear(); // showNotification('Welcome!'); // Initial welcome can be noisy }); function initializeTasks() { // Add Date objects and ensure IDs tasks.forEach(task => { task.id = task.id || Date.now() + Math.floor(Math.random() * 1000); task.startDateObj = new Date(task.startDate + 'T00:00:00'); task.endDateObj = new Date(task.endDate + 'T00:00:00'); }); // tasks.sort((a, b) => a.startDateObj - b.startDateObj); // Optional sort } // FIX 3: Initialize collapsed state based on showAllTasks function initializeCollapsedState() { stageCollapsed = {}; // Clear existing if (!showAllTasks) { // If we start collapsed (which is the default) STAGES.forEach(stage => { stageCollapsed[stage] = true; }); } } function populateSelectOptions() { const stageSelect = document.getElementById('taskStage'); const assigneeSelect = document.getElementById('taskAssignee'); stageSelect.innerHTML = STAGES.map(stage => `<option value="${stage}">${stage}</option>`).join(''); assigneeSelect.innerHTML = users.map(user => `<option value="${user.name}">${user.name}${user.isCurrentUser ? ' (You)' : ''}</option>`).join(''); assigneeSelect.innerHTML += '<option value="">Unassigned</option>'; } function setupEventListeners() { document.addEventListener('mousemove', onDrag); document.addEventListener('mouseup', onDrop); document.addEventListener('click', handleClickOutside); document.addEventListener('keydown', handleKeyboardNavigation); taskModal.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideModal(); }); contextMenu.addEventListener('keydown', handleContextMenuKeydown); keyboardShortcuts.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideKeyboardShortcuts(); }); window.addEventListener('resize', debounce(onWindowResize, 200)); } function setCopyrightYear() { document.getElementById('currentYear').textContent = new Date().getFullYear(); } function debounce(func, wait) { let timeout; return function executedFunction(...args) { const later = () => { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; } // --- Core Rendering Logic --- function render() { const isMobile = window.innerWidth <= 768; // FIX 1: Set body classes for CSS rules document.body.classList.toggle('is-mobile', isMobile); document.body.classList.toggle('is-desktop', !isMobile); if (isMobile) { renderMobileView(); } else { renderChart(); } updateTaskDetailsSection(); // Update details regardless of view } function onWindowResize() { render(); } // --- Desktop Gantt Rendering --- function renderChart() { if (!leftPane || !rightPane || !timelineHeader || !taskContainers || !ganttChartElement) return; // Ensure desktop elements exist const { startDate, totalDays } = getTimelineRange(); // Calculate actual day width based on the container's available space // Ensure rightPane has rendered and has width before calculating const rightPaneWidth = rightPane.offsetWidth; const dayWidth = rightPaneWidth > 0 ? rightPaneWidth / totalDays : parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--day-min-width')); // Fallback // Set CSS variables used by styles ganttChartElement.style.setProperty('--total-days', totalDays); // FIX 4: Set the --day-width CSS variable for background grid lines ganttChartElement.style.setProperty('--day-width', `${dayWidth}px`); renderTimelineHeader(startDate, totalDays); renderPanes(startDate, totalDays, dayWidth); // Pass dayWidth renderTodayMarker(startDate, totalDays, dayWidth); // Pass dayWidth } function getTimelineRange() { const today = new Date(); today.setHours(0, 0, 0, 0); const bufferDays = 7; const minTotalDays = 30; // Minimum number of days to display let minDate = new Date(today); minDate.setDate(today.getDate() - bufferDays); // Default start let maxDate = new Date(today); maxDate.setDate(today.getDate() + minTotalDays - bufferDays); // Default end if (tasks.length > 0) { const allDates = tasks.flatMap(t => [t.startDateObj, t.endDateObj]); allDates.push(today); // Ensure today is included const taskMinDate = new Date(Math.min(...allDates)); const taskMaxDate = new Date(Math.max(...allDates)); minDate = new Date(taskMinDate); minDate.setDate(minDate.getDate() - bufferDays); maxDate = new Date(taskMaxDate); maxDate.setDate(maxDate.getDate() + bufferDays); } const calculatedTotalDays = Math.ceil((maxDate - minDate) / (1000 * 60 * 60 * 24)) + 1; const totalDays = Math.max(minTotalDays, calculatedTotalDays); // Adjust maxDate if totalDays was forced to minimum if (totalDays > calculatedTotalDays) { maxDate = new Date(minDate); maxDate.setDate(minDate.getDate() + totalDays - 1); } return { startDate: minDate, totalDays }; } function renderTimelineHeader(startDate, totalDays) { if (!timelineHeader) return; // Set grid columns for the header itself timelineHeader.style.gridTemplateColumns = `repeat(${totalDays}, minmax(var(--day-min-width), 1fr))`; timelineHeader.innerHTML = ''; const fragment = document.createDocumentFragment(); // Optimize DOM insertion for (let i = 0; i < totalDays; i++) { const date = new Date(startDate); date.setDate(startDate.getDate() + i); const dayOfWeek = date.getDay(); const isWeekend = (dayOfWeek === 0 || dayOfWeek === 6); const dayDiv = document.createElement('div'); // FIX 2: Format adjusted slightly, relying on increased --day-min-width dayDiv.textContent = date.toLocaleDateString('en-US', { day: 'numeric', weekday: 'short' }); dayDiv.title = date.toLocaleDateString('en-US', { dateStyle: 'full' }); if (isWeekend) dayDiv.classList.add('weekend'); fragment.appendChild(dayDiv); } timelineHeader.appendChild(fragment); // Set explicit width on header to match content width // This helps sync scrolling with #taskContainers timelineHeader.style.width = `calc(${totalDays} * var(--day-width))`; } function renderTodayMarker(startDate, totalDays, dayWidth) { if (!rightPane) return; const existingMarker = rightPane.querySelector('.today-marker'); if (existingMarker) existingMarker.remove(); const today = new Date(); today.setHours(0, 0, 0, 0); const diffDays = (today - startDate) / (1000 * 60 * 60 * 24); if (diffDays >= 0 && diffDays < totalDays && dayWidth > 0) { const todayMarker = document.createElement('div'); todayMarker.className = 'today-marker'; // Use pixel positioning based on calculated dayWidth for better accuracy todayMarker.style.left = `${diffDays * dayWidth}px`; todayMarker.title = 'Today'; // Append to taskContainers to ensure it's positioned relative to the scrollable content area taskContainers.appendChild(todayMarker); } } function renderPanes(startDate, totalDays, dayWidth) { if (!leftPane || !taskContainers) return; const leftPaneFragment = document.createDocumentFragment(); const taskContainersFragment = document.createDocumentFragment(); taskBarElements = {}; // Define SVG Chevron Icon once const chevronSvg = `<svg class="toggle-icon" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"/></svg>`; // Set width for the task container area to match the timeline header taskContainers.style.width = `calc(${totalDays} * var(--day-width))`; STAGES.forEach(stage => { const stageDiv = document.createElement('button'); stageDiv.className = 'stage-name'; stageDiv.type = 'button'; const isCollapsed = stageCollapsed[stage]; // Check current state if (isCollapsed) stageDiv.classList.add('collapsed'); stageDiv.setAttribute('aria-expanded', !isCollapsed); stageDiv.innerHTML = `${chevronSvg} ${stage}`; stageDiv.onclick = () => toggleStage(stage); leftPaneFragment.appendChild(stageDiv); // Empty placeholder row for stage header alignment in right pane const stageHeaderContainer = document.createElement('div'); stageHeaderContainer.className = 'task-container stage-header-row'; stageHeaderContainer.style.display = isCollapsed ? 'none' : 'block'; // Hide if collapsed taskContainersFragment.appendChild(stageHeaderContainer); const stageTasks = tasks.filter(t => t.stage === stage); // "No tasks" message elements const noTasksNameDiv = document.createElement('div'); noTasksNameDiv.className = 'task-name no-tasks-message'; noTasksNameDiv.textContent = 'No tasks in this stage'; noTasksNameDiv.style.display = (stageTasks.length === 0 && !isCollapsed) ? 'flex' : 'none'; // Show only if stage expanded and no tasks leftPaneFragment.appendChild(noTasksNameDiv); const noTasksContainer = document.createElement('div'); noTasksContainer.className = 'task-container no-tasks-row'; noTasksContainer.style.display = (stageTasks.length === 0 && !isCollapsed) ? 'block' : 'none'; // Show only if stage expanded and no tasks taskContainersFragment.appendChild(noTasksContainer); // Render actual tasks for the stage stageTasks.forEach(task => { // Task Name Element (Left Pane) const taskDiv = document.createElement('div'); taskDiv.className = 'task-name'; taskDiv.textContent = task.name; taskDiv.setAttribute('data-task-id', task.id); taskDiv.setAttribute('tabindex', '0'); taskDiv.setAttribute('role', 'button'); taskDiv.title = `${task.name}\n${task.description || ''}`; taskDiv.oncontextmenu = (e) => showContextMenu(e, task); taskDiv.onclick = () => selectTask(task.id); taskDiv.ondblclick = () => showModal(task); taskDiv.onfocus = () => selectTask(task.id); taskDiv.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); showModal(task); } }; taskDiv.style.display = isCollapsed ? 'none' : 'flex'; // Hide if collapsed leftPaneFragment.appendChild(taskDiv); // Task Container & Bar (Right Pane) const taskContainer = document.createElement('div'); taskContainer.className = 'task-container'; taskContainer.style.display = isCollapsed ? 'none' : 'block'; // Hide if collapsed const taskBar = document.createElement('div'); taskBar.className = `task-bar ${task.priority}`; taskBar.setAttribute('data-task-id', task.id); taskBar.setAttribute('tabindex', '0'); taskBar.setAttribute('role', 'button'); taskBar.setAttribute('aria-label', `${task.name}, ${task.startDate} to ${task.endDate}`); const taskStart = task.startDateObj; const taskEnd = task.endDateObj; const startOffsetDays = (taskStart - startDate) / (1000 * 60 * 60 * 24); const durationDays = Math.max(0, (taskEnd - taskStart) / (1000 * 60 * 60 * 24)) + 1; // Calculate pixel positions based on dayWidth for accuracy const startPx = Math.max(0, startOffsetDays * dayWidth); const widthPx = Math.max(dayWidth, durationDays * dayWidth); // Ensure minimum width of 1 day const totalWidthPx = totalDays * dayWidth; // Clamp bar within the timeline boundaries const clampedStartPx = Math.max(0, startPx); const clampedWidthPx = Math.min(widthPx, totalWidthPx - clampedStartPx); taskBar.style.left = `${clampedStartPx}px`; taskBar.style.width = `${clampedWidthPx}px`; const taskNameSpan = document.createElement('span'); taskNameSpan.textContent = task.name; taskBar.appendChild(taskNameSpan); if (task.assignee) { const assigneeSpan = document.createElement('span'); assigneeSpan.className = 'task-assignee'; assigneeSpan.textContent = task.assignee; taskBar.appendChild(assigneeSpan); } taskBar.onmousedown = (e) => { if (!e.target.closest('.resize-handle')) startDrag(e, task, taskBar); }; taskBar.oncontextmenu = (e) => showContextMenu(e, task); taskBar.onclick = (e) => { e.stopPropagation(); selectTask(task.id); }; taskBar.ondblclick = (e) => { e.stopPropagation(); showModal(task); }; taskBar.onfocus = () => selectTask(task.id); taskBar.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); showModal(task); } }; // Resize Handles const leftHandle = document.createElement('div'); leftHandle.className = 'resize-handle left'; leftHandle.setAttribute('aria-label', 'Resize start date'); leftHandle.onmousedown = (e) => startResize(e, task, 'left'); const rightHandle = document.createElement('div'); rightHandle.className = 'resize-handle right'; rightHandle.setAttribute('aria-label', 'Resize end date'); rightHandle.onmousedown = (e) => startResize(e, task, 'right'); taskBar.appendChild(leftHandle); taskBar.appendChild(rightHandle); taskContainer.appendChild(taskBar); taskContainersFragment.appendChild(taskContainer); taskBarElements[task.id] = taskBar; }); }); // Clear existing content and append fragments leftPane.innerHTML = ''; taskContainers.innerHTML = ''; // Clear previous task rows/bars leftPane.appendChild(leftPaneFragment); taskContainers.appendChild(taskContainersFragment); // Re-apply selection styling if (selectedTaskId) { selectTask(selectedTaskId, false); } // Pass false to avoid redundant detail update } function toggleStage(stage) { stageCollapsed[stage] = !stageCollapsed[stage]; renderChart(); // Re-render needed to show/hide rows } // --- Drag & Resize Logic (Updated for Pixel-based calculation) --- function startDrag(e, task, taskBar) { e.preventDefault(); if (e.button !== 0) return; // Only left click selectTask(task.id); draggingTask = task; const barRect = taskBar.getBoundingClientRect(); dragOffsetX = e.clientX - barRect.left; // Offset relative to bar start taskBar.classList.add('dragging'); document.body.style.cursor = 'grabbing'; } function startResize(e, task, side) { e.preventDefault(); e.stopPropagation(); if (e.button !== 0) return; selectTask(task.id); resizingTask = task; resizeSide = side; // Store initial mouse position relative to the pane for accurate delta calculation const rightPaneRect = rightPane.getBoundingClientRect(); dragOffsetX = e.clientX - rightPaneRect.left; document.body.style.cursor = 'ew-resize'; } function onDrag(e) { if (!draggingTask && !resizingTask) return; if (!rightPane) return; const { startDate, totalDays } = getTimelineRange(); const containerWidth = parseFloat(taskContainers.style.width) || rightPane.offsetWidth; // Use task container width const dayWidth = containerWidth / totalDays; if (dayWidth <= 0) return; // Avoid issues const rightPaneRect = rightPane.getBoundingClientRect(); const mouseXInPane = e.clientX - rightPaneRect.left + rightPane.scrollLeft; // Mouse position relative to the scrollable content if (draggingTask) { const taskBar = taskBarElements[draggingTask.id]; if (!taskBar) return; const newX = mouseXInPane - dragOffsetX; // Calculate new left based on mouse movement and initial offset const currentDayIndex = Math.round(newX / dayWidth); const snappedX = currentDayIndex * dayWidth; const barWidthPx = parseFloat(taskBar.style.width); const maxLeftPx = containerWidth - barWidthPx; const newLeftPx = Math.max(0, Math.min(snappedX, maxLeftPx)); taskBar.style.left = `${newLeftPx}px`; } else if (resizingTask) { const taskBar = taskBarElements[resizingTask.id]; if (!taskBar) return; const currentLeftPx = parseFloat(taskBar.style.left); const currentWidthPx = parseFloat(taskBar.style.width); const minWidthPx = dayWidth; // Minimum width of 1 day if (resizeSide === 'left') { const currentRightPx = currentLeftPx + currentWidthPx; // Snap the new left edge to the nearest day boundary const targetLeftPx = Math.round(mouseXInPane / dayWidth) * dayWidth; // Ensure the new left doesn't go past the right edge minus minimum width let newLeftPx = Math.min(targetLeftPx, currentRightPx - minWidthPx); newLeftPx = Math.max(0, newLeftPx); // Ensure not less than 0 const newWidthPx = currentRightPx - newLeftPx; if (newWidthPx >= minWidthPx) { taskBar.style.left = `${newLeftPx}px`; taskBar.style.width = `${newWidthPx}px`; } } else { // right // Snap the new right edge to the nearest day boundary const targetRightPx = Math.round(mouseXInPane / dayWidth) * dayWidth; // Ensure the new right edge is at least minWidth away from the left edge let newRightPx = Math.max(targetRightPx, currentLeftPx + minWidthPx); newRightPx = Math.min(newRightPx, containerWidth); // Ensure not greater than total width const newWidthPx = newRightPx - currentLeftPx; if (newWidthPx >= minWidthPx) { taskBar.style.width = `${newWidthPx}px`; } } } } function onDrop(e) { document.body.style.cursor = 'default'; if (!draggingTask && !resizingTask) return; const { startDate, totalDays } = getTimelineRange(); const containerWidth = parseFloat(taskContainers.style.width) || rightPane.offsetWidth; const dayWidth = containerWidth / totalDays; if (dayWidth <= 0) { // Reset state if invalid draggingTask = null; resizingTask = null; renderChart(); return; } let taskToUpdate = draggingTask || resizingTask; if (!taskToUpdate) return; const taskBar = taskBarElements[taskToUpdate.id]; if (!taskBar) { draggingTask = null; resizingTask = null; return; } const finalLeftPx = parseFloat(taskBar.style.left); const finalWidthPx = parseFloat(taskBar.style.width); const newStartDayIndex = Math.round(finalLeftPx / dayWidth); const durationDays = Math.max(1, Math.round(finalWidthPx / dayWidth)); const newStartDate = new Date(startDate); newStartDate.setDate(startDate.getDate() + newStartDayIndex); const newEndDate = new Date(newStartDate); newEndDate.setDate(newStartDate.getDate() + durationDays - 1); taskToUpdate.startDate = newStartDate.toISOString().split('T')[0]; taskToUpdate.endDate = newEndDate.toISOString().split('T')[0]; taskToUpdate.startDateObj = newStartDate; taskToUpdate.endDateObj = newEndDate; const action = draggingTask ? 'moved' : 'resized'; showNotification(`Task "${escapeHTML(taskToUpdate.name)}" ${action}`); const updatedTaskId = taskToUpdate.id; draggingTask = null; resizingTask = null; renderChart(); // Re-render to finalize positions and apply changes selectTask(updatedTaskId); // Reselect after render } // --- Task Selection & Details --- function selectTask(taskId, updateDetails = true) { // Added flag to control detail update const previouslySelectedId = selectedTaskId; selectedTaskId = taskId; // Update Desktop Gantt Highlighting document.querySelectorAll('.task-bar.focused').forEach(bar => bar.classList.remove('focused')); document.querySelectorAll('.task-name.focused-row').forEach(row => row.classList.remove('focused-row')); if (taskId) { const taskBar = taskBarElements[taskId]; if (taskBar) taskBar.classList.add('focused'); const taskNameRow = document.querySelector(`.task-name[data-task-id="${taskId}"]`); if (taskNameRow) taskNameRow.classList.add('focused-row'); } // Update Task Details Section only if selection changed and flag is true if (previouslySelectedId !== selectedTaskId && updateDetails) { updateTaskDetailsSection(); } } function updateTaskDetailsSection() { if (!taskDetailsSection) return; const task = tasks.find(t => t.id === selectedTaskId); const isDesktop = window.innerWidth > 768; if (task && isDesktop) { const start = task.startDateObj.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); const end = task.endDateObj.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); const duration = Math.round((task.endDateObj - task.startDateObj) / (1000 * 60 * 60 * 24)) + 1; taskDetailsSection.innerHTML = ` <h3>${escapeHTML(task.name)}</h3> ${task.description ? `<p>${escapeHTML(task.description)}</p>` : '<p><em>No description provided.</em></p>'} <div class="details-grid"> <p><strong>Priority:</strong> <span class="priority-${task.priority}">${escapeHTML(task.priority.charAt(0).toUpperCase() + task.priority.slice(1))}</span></p> <p><strong>Stage:</strong> ${escapeHTML(task.stage)}</p> <p><strong>Assignee:</strong> ${escapeHTML(task.assignee || 'Unassigned')}</p> <p><strong>Start Date:</strong> ${start}</p> <p><strong>End Date:</strong> ${end}</p> <p><strong>Duration:</strong> ${duration} day${duration > 1 ? 's' : ''}</p> </div> `; taskDetailsSection.classList.add('visible'); } else { // taskDetailsSection.innerHTML = `<p>Select a task to see its details.</p>`; // Keep placeholder if no task selected taskDetailsSection.classList.remove('visible'); // Hide if no task or mobile } } // --- Modal Logic --- function showModal(task = null) { const title = document.getElementById('modalTitle'); const nameInput = document.getElementById('taskName'); const deleteBtn = document.getElementById('deleteTaskBtn'); if (task) { title.textContent = 'Edit Task'; nameInput.value = task.name; document.getElementById('taskDescription').value = task.description || ''; document.getElementById('taskPriority').value = task.priority; document.getElementById('taskStage').value = task.stage; document.getElementById('taskAssignee').value = task.assignee || ''; document.getElementById('startDate').value = task.startDate; document.getElementById('endDate').value = task.endDate; taskModal.dataset.editId = task.id; deleteBtn.style.display = 'block'; } else { title.textContent = 'Add New Task'; nameInput.value = ''; document.getElementById('taskDescription').value = ''; document.getElementById('taskPriority').value = 'medium'; document.getElementById('taskStage').value = STAGES[0]; const currentUser = users.find(u => u.isCurrentUser); document.getElementById('taskAssignee').value = currentUser ? currentUser.name : (users.length > 0 ? users[0].name : ''); const today = new Date(); const nextWeek = new Date(today); nextWeek.setDate(today.getDate() + 6); document.getElementById('startDate').value = today.toISOString().split('T')[0]; document.getElementById('endDate').value = nextWeek.toISOString().split('T')[0]; delete taskModal.dataset.editId; deleteBtn.style.display = 'none'; } taskModal.style.display = 'flex'; requestAnimationFrame(() => { taskModal.classList.add('show'); }); setTimeout(() => nameInput.focus(), 50); } function hideModal() { taskModal.classList.remove('show'); taskModal.addEventListener('transitionend', () => { taskModal.style.display = 'none'; }, { once: true }); } function saveTask() { const name = document.getElementById('taskName').value.trim(); const description = document.getElementById('taskDescription').value.trim(); const priority = document.getElementById('taskPriority').value; const stage = document.getElementById('taskStage').value; const assignee = document.getElementById('taskAssignee').value; const startDateStr = document.getElementById('startDate').value; const endDateStr = document.getElementById('endDate').value; if (!name || !startDateStr || !endDateStr) { showNotification('Task Name, Start Date, and End Date are required.', 'error'); return; } const startDate = new Date(startDateStr + 'T00:00:00'); const endDate = new Date(endDateStr + 'T00:00:00'); if (endDate < startDate) { showNotification('End Date cannot be before Start Date.', 'error'); return; } const editId = taskModal.dataset.editId; let affectedTaskId; if (editId) { const taskIndex = tasks.findIndex(t => t.id === Number(editId)); if (taskIndex !== -1) { tasks[taskIndex] = { ...tasks[taskIndex], name, description, priority, stage, assignee, startDate: startDateStr, endDate: endDateStr, startDateObj: startDate, endDateObj: endDate }; affectedTaskId = Number(editId); showNotification(`Task "${escapeHTML(name)}" updated`); } } else { const newTask = { id: Date.now(), name, description, priority, stage, assignee, startDate: startDateStr, endDate: endDateStr, startDateObj: startDate, endDateObj: endDate }; tasks.push(newTask); affectedTaskId = newTask.id; showNotification(`Task "${escapeHTML(name)}" added`); selectedTaskId = newTask.id; // Select the newly added task } hideModal(); render(); // Re-render the view // Ensure the newly added/edited task is selected and details shown (if applicable) if (affectedTaskId) { selectTask(affectedTaskId); // Ensure it's selected after render } } function deleteTaskFromModal() { const editId = taskModal.dataset.editId; if (editId && confirm('Are you sure you want to delete this task?')) { const taskIndex = tasks.findIndex(t => t.id === Number(editId)); if (taskIndex !== -1) { const taskName = tasks[taskIndex].name; tasks.splice(taskIndex, 1); showNotification(`Task "${escapeHTML(taskName)}" deleted`); if (selectedTaskId === Number(editId)) selectedTaskId = null; hideModal(); render(); } } else if (!editId) { hideModal(); } } // --- Context Menu Logic --- function showContextMenu(e, task) { e.preventDefault(); e.stopPropagation(); hideContextMenu(); currentContextTask = task; selectTask(task.id); contextMenu.style.display = 'block'; const menuWidth = contextMenu.offsetWidth; const menuHeight = contextMenu.offsetHeight; const { clientX: mouseX, clientY: mouseY } = e; const { innerWidth: vpWidth, innerHeight: vpHeight } = window; let x = mouseX; let y = mouseY; if (x + menuWidth > vpWidth - 10) x = vpWidth - menuWidth - 10; // Add buffer if (y + menuHeight > vpHeight - 10) y = vpHeight - menuHeight - 10; // Add buffer if (x < 10) x = 10; // Prevent going off left if (y < 10) y = 10; // Prevent going off top contextMenu.style.left = `${x}px`; contextMenu.style.top = `${y}px`; setTimeout(() => { const firstItem = contextMenu.querySelector('[role="menuitem"]'); if (firstItem) firstItem.focus(); }, 0); } function hideContextMenu() { if (contextMenu.style.display === 'block') { contextMenu.style.display = 'none'; currentContextTask = null; } } function handleContextMenuKeydown(e) { if (contextMenu.style.display !== 'block') return; const items = Array.from(contextMenu.querySelectorAll('[role="menuitem"]')); const currentIndex = items.indexOf(document.activeElement); switch (e.key) { case 'Escape': hideContextMenu(); break; case 'ArrowUp': e.preventDefault(); const prevIndex = (currentIndex > 0) ? currentIndex - 1 : items.length - 1; items[prevIndex]?.focus(); break; case 'ArrowDown': e.preventDefault(); const nextIndex = (currentIndex < items.length - 1) ? currentIndex + 1 : 0; items[nextIndex]?.focus(); break; case 'Enter': case ' ': e.preventDefault(); document.activeElement?.click(); break; case 'Tab': hideContextMenu(); break; } } function editTask() { const taskToEdit = currentContextTask || (selectedTaskId ? tasks.find(t => t.id === selectedTaskId) : null); if (taskToEdit) showModal(taskToEdit); hideContextMenu(); } function duplicateTask() { const taskToDuplicate = currentContextTask || (selectedTaskId ? tasks.find(t => t.id === selectedTaskId) : null); if (taskToDuplicate) { const newTask = { ...taskToDuplicate, id: Date.now(), name: `${taskToDuplicate.name} (Copy)` }; newTask.startDateObj = new Date(taskToDuplicate.startDateObj); newTask.endDateObj = new Date(taskToDuplicate.endDateObj); tasks.push(newTask); selectedTaskId = newTask.id; // Select the new task render(); showNotification(`Task "${escapeHTML(taskToDuplicate.name)}" duplicated`); } hideContextMenu(); } function deleteTask() { const taskToDelete = currentContextTask || (selectedTaskId ? tasks.find(t => t.id === selectedTaskId) : null); if (taskToDelete && confirm(`Are you sure you want to delete task "${escapeHTML(taskToDelete.name)}"?`)) { const taskId = taskToDelete.id; tasks = tasks.filter(t => t.id !== taskId); if (selectedTaskId === taskId) selectedTaskId = null; render(); showNotification(`Task "${escapeHTML(taskToDelete.name)}" deleted`); } hideContextMenu(); } // --- UI Toggles & Search --- function toggleKeyboardShortcuts() { const isVisible = keyboardShortcuts.style.display === 'block'; if (isVisible) hideKeyboardShortcuts(); else { keyboardShortcuts.style.display = 'block'; keyboardShortcuts.focus(); } } function hideKeyboardShortcuts() { keyboardShortcuts.style.display = 'none'; } function toggleShowAll() { showAllTasks = !showAllTasks; initializeCollapsedState(); // Re-initialize based on the new showAllTasks value renderChart(); updateToggleButtonText(); // Optionally, re-select task if its stage was just expanded if (showAllTasks && selectedTaskId) { selectTask(selectedTaskId); } } function updateToggleButtonText() { const btn = document.getElementById('toggleShowAllBtn'); if (btn) btn.textContent = showAllTasks ? 'Collapse All' : 'Expand All'; } function clearSearch() { searchInput.value = ''; searchTasks(); searchInput.focus(); } window.debouncedSearchTasks = debounce(searchTasks, 300); function searchTasks() { const searchTerm = searchInput.value.toLowerCase().trim(); const isDesktop = window.innerWidth > 768; if (!isDesktop || !leftPane || !taskContainers) return; // Desktop only let firstMatchElement = null; const currentStageCollapsed = { ...stageCollapsed }; // Store state before search potentially expands let needsRerender = false; tasks.forEach(task => { const isMatch = (task.name.toLowerCase().includes(searchTerm) || task.description?.toLowerCase().includes(searchTerm) || task.assignee?.toLowerCase().includes(searchTerm)); const nameEl = leftPane.querySelector(`.task-name[data-task-id="${task.id}"]`); const barEl = taskBarElements[task.id]; // If match found in a collapsed stage, mark for expansion if (isMatch && stageCollapsed[task.stage]) { delete stageCollapsed[task.stage]; // Temporarily expand for visibility needsRerender = true; } // Apply highlighting/dimming after potential re-render check if (nameEl) nameEl.classList.toggle('highlight', isMatch && searchTerm); if (barEl) barEl.style.opacity = (isMatch || !searchTerm) ? '1' : '0.3'; if (isMatch && !firstMatchElement && nameEl) firstMatchElement = nameEl; }); // If search term cleared, restore original collapsed state if (!searchTerm && needsRerender) { stageCollapsed = currentStageCollapsed; needsRerender = true; // Force re-render to collapse stages again } // Re-render if any stage was expanded/collapsed if (needsRerender) { renderChart(); // Need to re-find first match element after re-render if (searchTerm) { firstMatchElement = leftPane.querySelector(`.task-name[data-task-id="${tasks.find(task => (task.name.toLowerCase().includes(searchTerm) || task.description?.toLowerCase().includes(searchTerm) || task.assignee?.toLowerCase().includes(searchTerm)))?.id}"]`); } } // Scroll to first match if found and visible if (firstMatchElement) { firstMatchElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); } } // --- Mobile View Logic --- function setupMobileView() { mobileCurrentDate.setHours(0, 0, 0, 0); updateMobileDateDisplay(); // Initial render happens in main render() function } function updateMobileDateDisplay() { const display = document.getElementById('mobileDateDisplay'); if (!display) return; const today = new Date(); today.setHours(0,0,0,0); let formatOptions = { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }; let dateString = mobileCurrentDate.toLocaleDateString('en-US', formatOptions); if (mobileCurrentDate.getTime() === today.getTime()) { dateString = `Today (${dateString})`; } display.textContent = dateString; } function navigateMobileDate(direction) { if (direction === 0) { mobileCurrentDate = new Date(); } else { mobileCurrentDate.setDate(mobileCurrentDate.getDate() + direction); } mobileCurrentDate.setHours(0, 0, 0, 0); renderMobileView(); // Re-render mobile list for the new date } function renderMobileView() { updateMobileDateDisplay(); if (!mobileTaskList) return; mobileTaskList.innerHTML = ''; // Clear list const fragment = document.createDocumentFragment(); const currentDateStart = new Date(mobileCurrentDate); currentDateStart.setHours(0, 0, 0, 0); const currentDateEnd = new Date(mobileCurrentDate); currentDateEnd.setHours(23, 59, 59, 999); const tasksForDay = tasks.filter(task => task.startDateObj <= currentDateEnd && task.endDateObj >= currentDateStart) .sort((a, b) => a.startDateObj - b.startDateObj || a.name.localeCompare(b.name)); // Sort by start then name if (tasksForDay.length === 0) { mobileTaskList.innerHTML = '<p class="no-tasks">No tasks scheduled for this day.</p>'; return; } tasksForDay.forEach(task => { const card = document.createElement('div'); card.className = `mobile-task-card ${task.priority}`; card.setAttribute('data-task-id', task.id); card.setAttribute('tabindex', '0'); card.onclick = () => showModal(task); card.onkeydown = (e) => { if(e.key === 'Enter' || e.key === ' ') { e.preventDefault(); showModal(task); }}; const start = task.startDateObj.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); const end = task.endDateObj.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); const descSnippet = task.description ? escapeHTML(task.description.substring(0, 80)) + (task.description.length > 80 ? '...' : '') : ''; card.innerHTML = ` <h4>${escapeHTML(task.name)}</h4> <p class="dates">${start} - ${end}</p> ${task.assignee ? `<p class="assignee">Assignee: ${escapeHTML(task.assignee)}</p>` : ''} ${descSnippet ? `<p>${descSnippet}</p>` : ''} `; fragment.appendChild(card); }); mobileTaskList.appendChild(fragment); } // --- User Avatars & Notifications --- function initializeUsers() { const usersListContainer = document.getElementById('usersOnline'); if (!usersListContainer) return; usersListContainer.innerHTML = ''; const fragment = document.createDocumentFragment(); users.forEach(user => { const userAvatar = document.createElement('div'); userAvatar.className = 'user-avatar'; userAvatar.style.backgroundColor = user.color; const initials = user.name.split(' ').map(n => n[0]?.toUpperCase()).join('').substring(0, 2); userAvatar.textContent = initials || '?'; userAvatar.title = user.name + (user.isCurrentUser ? ' (You)' : ''); fragment.appendChild(userAvatar); }); usersListContainer.appendChild(fragment); } function showNotification(message, type = 'success') { clearTimeout(notificationTimer); notificationElement.className = 'notification'; notificationElement.textContent = message; void notificationElement.offsetWidth; let bgColor; switch (type) { case 'error': bgColor = 'var(--accent-red)'; break; case 'info': bgColor = 'var(--primary-color)'; break; case 'success': default: bgColor = 'var(--accent-green)'; break; } notificationElement.style.backgroundColor = bgColor; notificationElement.classList.add('show'); notificationTimer = setTimeout(() => { notificationElement.classList.add('hide'); setTimeout(() => { notificationElement.classList.remove('show', 'hide'); }, 400); // Reset classes after fade }, 3500); } // --- Helper Functions --- function escapeHTML(str) { if (!str) return ''; const map = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }; // Corrected escape map return str.replace(/[&<>"']/g, m => map[m]); } function handleClickOutside(e) { if (contextMenu.style.display === 'block' && !contextMenu.contains(e.target) && !e.target.closest('.task-name, .task-bar')) { hideContextMenu(); } const shortcutButton = document.querySelector('button[onclick="toggleKeyboardShortcuts()"]'); if (keyboardShortcuts.style.display === 'block' && !keyboardShortcuts.contains(e.target) && e.target !== shortcutButton) { hideKeyboardShortcuts(); } if (taskModal.classList.contains('show') && e.target === taskModal) { hideModal(); } } function nudgeTask(days) { if (!selectedTaskId) return; const task = tasks.find(t => t.id === selectedTaskId); if (task) { const startDate = new Date(task.startDateObj); const endDate = new Date(task.endDateObj); const duration = (endDate - startDate); startDate.setDate(startDate.getDate() + days); endDate.setTime(startDate.getTime() + duration); task.startDate = startDate.toISOString().split('T')[0]; task.endDate = endDate.toISOString().split('T')[0]; task.startDateObj = startDate; task.endDateObj = endDate; renderChart(); selectTask(task.id); } } // --- Keyboard Navigation Handler --- function handleKeyboardNavigation(e) { const isModalVisible = taskModal.classList.contains('show'); const isContextMenuVisible = contextMenu.style.display === 'block'; const isShortcutsVisible = keyboardShortcuts.style.display === 'block'; const activeTag = document.activeElement.tagName; const isInputFocused = ['INPUT', 'TEXTAREA', 'SELECT'].includes(activeTag); const isBodyFocused = activeTag === 'BODY'; const isMobile = window.innerWidth <= 768; if (isModalVisible) { if (e.key === 'Escape') hideModal(); return; } if (isContextMenuVisible) { handleContextMenuKeydown(e); return; } if (isShortcutsVisible) { if (e.key === 'Escape') hideKeyboardShortcuts(); return; } if (isInputFocused && e.key !== 'Escape') return; switch (e.key) { case 'n': case 'N': if (!isInputFocused) { e.preventDefault(); showModal(); } break; case 'e': case 'E': if (!isInputFocused && selectedTaskId && !isMobile) { e.preventDefault(); editTask(); // Use editTask function } break; case 'Enter': if (document.activeElement.classList.contains('task-bar') || document.activeElement.classList.contains('task-name')) { const taskId = Number(document.activeElement.dataset.taskId); if (taskId) { e.preventDefault(); const task = tasks.find(t => t.id === taskId); if (task) showModal(task); } } else if (document.activeElement.classList.contains('stage-name')) { e.preventDefault(); document.activeElement.click(); // Simulate click to toggle } else if (document.activeElement.classList.contains('mobile-task-card')) { e.preventDefault(); document.activeElement.click(); // Simulate click to open modal } break; case 'Delete': if (!isInputFocused && selectedTaskId && !isMobile) { e.preventDefault(); deleteTask(); // Uses context menu delete logic } break; case 'ArrowUp': case 'ArrowDown': if (!isInputFocused && !isMobile) { e.preventDefault(); navigateTaskSelection(e.key === 'ArrowUp' ? 'up' : 'down'); } break; case 'ArrowLeft': case 'ArrowRight': if (!isInputFocused && !isMobile && selectedTaskId) { e.preventDefault(); nudgeTask(e.key === 'ArrowLeft' ? -1 : 1); } break; case ' ': // Spacebar if (document.activeElement.classList.contains('task-bar') || document.activeElement.classList.contains('task-name')) { e.preventDefault(); const taskId = Number(document.activeElement.dataset.taskId); if (taskId) { selectTask(taskId === selectedTaskId ? null : taskId); } } else if (document.activeElement.classList.contains('stage-name')) { e.preventDefault(); document.activeElement.click(); // Simulate click } break; case 'c': case 'C': if (!isInputFocused && !isMobile) { e.preventDefault(); toggleShowAll(); } break; case 'Escape': e.preventDefault(); if (selectedTaskId && !isMobile) { selectTask(null); } else if (!isInputFocused && !isBodyFocused) { document.activeElement?.blur(); } // Blur active element if not input/body else { hideContextMenu(); hideModal(); hideKeyboardShortcuts(); } break; case '?': if (!isInputFocused) { e.preventDefault(); toggleKeyboardShortcuts(); } break; } } function navigateTaskSelection(direction) { // Find all *visible* task name elements in the left pane const taskElements = Array.from(leftPane.querySelectorAll('.task-name[data-task-id]:not([style*="display: none"])')); if (!taskElements.length) return; let currentIndex = -1; if (selectedTaskId) { currentIndex = taskElements.findIndex(el => Number(el.dataset.taskId) === selectedTaskId); } let newIndex; if (direction === 'up') { newIndex = currentIndex > 0 ? currentIndex - 1 : taskElements.length - 1; } else { newIndex = (currentIndex !== -1 && currentIndex < taskElements.length - 1) ? currentIndex + 1 : 0; } // Handle no selection case if (taskElements[newIndex]) { const newTaskId = Number(taskElements[newIndex].dataset.taskId); const targetElement = taskElements[newIndex]; selectTask(newTaskId); // Select the task logically targetElement.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); // Defer focus slightly to ensure it takes after potential scroll/render updates setTimeout(() => targetElement.focus({ preventScroll: true }), 50); // preventScroll might help } } function hideQuickGuide() { const guideBox = document.getElementById('quickGuideBox'); if (guideBox) { guideBox.classList.add('hidden'); // Use localStorage to remember dismissal for this session try { // Wrap in try...catch in case localStorage is unavailable/full localStorage.setItem('zenithGanttGuideDismissed', 'true'); } catch (e) { console.warn("Could not save guide dismissal state to localStorage.", e); } // Optional: Fully remove from DOM after transition for cleaner structure guideBox.addEventListener('transitionend', () => { if (guideBox.classList.contains('hidden')) { // Optional: You might want to just leave it hidden with CSS // guideBox.style.display = 'none'; } }, { once: true }); } } // Expose it to the global scope for the onclick handler window.hideQuickGuide = hideQuickGuide; // --- Expose functions needed by HTML event handlers --- window.showModal = showModal; window.hideModal = hideModal; window.saveTask = saveTask; window.deleteTaskFromModal = deleteTaskFromModal; window.toggleShowAll = toggleShowAll; window.toggleKeyboardShortcuts = toggleKeyboardShortcuts; window.searchTasks = searchTasks; window.debouncedSearchTasks = debouncedSearchTasks; // Correctly expose debounced version window.clearSearch = clearSearch; window.toggleStage = toggleStage; window.showContextMenu = showContextMenu; window.editTask = editTask; window.duplicateTask = duplicateTask; window.deleteTask = deleteTask; window.navigateMobileDate = navigateMobileDate; })(); // End of IIFE </script> </body> </html>
Like this project

Posted Jun 4, 2025

Created an interactive Gantt chart app for project management.