Files
jira-extractor/public/js/script.js
T

979 lines
38 KiB
JavaScript

const API_BASE = '';
let allTickets = [];
let filteredTickets = [];
let customFieldsMap = [];
let lastUsername = '';
let lastApiToken = '';
let currentGanttView = 'month';
let isGanttExpanded = false;
let currentSortCol = null;
let isAsc = true;
// --- JIRA STATUS CONFIG & MAPPING ---
const STATUS_DEFINITIONS = {
'Opportunity': {
label: 'Opportunity',
cssClass: 's-opportunity',
dark: {
color: '#c084fc',
bg: 'rgba(192, 132, 252, 0.16)',
border: '#a855f7',
badgeBg: '#9333ea',
badgeText: '#ffffff'
},
light: {
color: '#6d28d9',
bg: '#f5f3ff',
border: '#7c3aed',
badgeBg: '#7c3aed',
badgeText: '#ffffff'
},
drawioColor: '#9333ea'
},
'Delayed': {
label: 'Delayed',
cssClass: 's-delayed',
dark: {
color: '#94a3b8',
bg: 'rgba(148, 163, 184, 0.16)',
border: '#64748b',
badgeBg: '#475569',
badgeText: '#ffffff'
},
light: {
color: '#334155',
bg: '#f1f5f9',
border: '#64748b',
badgeBg: '#475569',
badgeText: '#ffffff'
},
drawioColor: '#64748b'
},
'To be Analysed': {
label: 'To be Analysed',
cssClass: 's-analyse',
dark: {
color: '#fbbf24',
bg: 'rgba(251, 191, 36, 0.16)',
border: '#f59e0b',
badgeBg: '#d97706',
badgeText: '#ffffff'
},
light: {
color: '#92400e',
bg: '#fffbeb',
border: '#d97706',
badgeBg: '#b45309',
badgeText: '#ffffff'
},
drawioColor: '#d97706'
},
'Analysed': {
label: 'Analysed',
cssClass: 's-analysed',
dark: {
color: '#22d3ee',
bg: 'rgba(34, 211, 238, 0.16)',
border: '#06b6d4',
badgeBg: '#0891b2',
badgeText: '#ffffff'
},
light: {
color: '#0e7490',
bg: '#ecfeff',
border: '#0891b2',
badgeBg: '#0e7490',
badgeText: '#ffffff'
},
drawioColor: '#0891b2'
},
'Project Ongoing': {
label: 'Project Ongoing',
cssClass: 's-ongoing',
dark: {
color: '#60a5fa',
bg: 'rgba(96, 165, 250, 0.20)',
border: '#3b82f6',
badgeBg: '#2563eb',
badgeText: '#ffffff'
},
light: {
color: '#1e40af',
bg: '#eff6ff',
border: '#2563eb',
badgeBg: '#1d4ed8',
badgeText: '#ffffff'
},
drawioColor: '#2563eb'
},
'Rejected': {
label: 'Rejected',
cssClass: 's-rejected',
dark: {
color: '#f87171',
bg: 'rgba(248, 113, 113, 0.18)',
border: '#ef4444',
badgeBg: '#dc2626',
badgeText: '#ffffff'
},
light: {
color: '#991b1b',
bg: '#fef2f2',
border: '#dc2626',
badgeBg: '#dc2626',
badgeText: '#ffffff'
},
drawioColor: '#dc2626'
},
'Done': {
label: 'Done',
cssClass: 's-done',
dark: {
color: '#34d399',
bg: 'rgba(52, 211, 153, 0.18)',
border: '#10b981',
badgeBg: '#059669',
badgeText: '#ffffff'
},
light: {
color: '#065f46',
bg: '#ecfdf5',
border: '#059669',
badgeBg: '#047857',
badgeText: '#ffffff'
},
drawioColor: '#059669'
}
};
function mapJiraStatus(rawStatus) {
if (!rawStatus) return 'Opportunity';
const s = String(rawStatus).trim().toLowerCase();
// "Resolved" and "Closed" => DONE
if (s.includes('done') || s.includes('resolv') || s.includes('clos') || s.includes('ferm') || s.includes('termin') || s.includes('rsolu')) {
return 'Done';
}
// "Rejected" => Rejected
if (s.includes('reject') || s.includes('rejet') || s.includes('refus') || s.includes('abandon')) {
return 'Rejected';
}
// "In Progress" => Project Ongoing
if (s.includes('progress') || s.includes('en cours') || s.includes('ongoing') || s.includes('review') || s.includes('dev')) {
return 'Project Ongoing';
}
// "On Hold" => Analysed
if (s.includes('hold') || s.includes('analysé') || s.includes('analysed') || s.includes('attente')) {
return 'Analysed';
}
// "Open" and "SPQI" => To be Analysed
if (s === 'open' || s.includes('spqi') || s.includes('ouvert') || s.includes('to analyse') || s.includes('to be analysed')) {
return 'To be Analysed';
}
// "Delayed" => Delayed
if (s.includes('delay') || s.includes('retard') || s.includes('reporté')) {
return 'Delayed';
}
// "New" and "Reopened" => Opportunity
if (s.includes('new') || s.includes('reopen') || s.includes('nouveau') || s.includes('opportunit') || s.includes('backlog')) {
return 'Opportunity';
}
return 'Opportunity';
}
function getStatusThemeConfig(mappedStatus) {
const isLight = document.documentElement.getAttribute('data-theme') === 'light';
const def = STATUS_DEFINITIONS[mappedStatus] || STATUS_DEFINITIONS['Opportunity'];
return isLight ? def.light : def.dark;
}
function getStatusClass(s) {
const mapped = mapJiraStatus(s);
const def = STATUS_DEFINITIONS[mapped];
return def ? def.cssClass : 's-opportunity';
}
// --- THEME MANAGEMENT (LIGHT / DARK) ---
function initTheme() {
const savedTheme = localStorage.getItem('jira_extractor_theme') || 'dark';
setTheme(savedTheme, false);
}
function toggleTheme() {
const currentTheme = document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
setTheme(newTheme, true);
}
function setTheme(theme, withToast = false) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('jira_extractor_theme', theme);
const icon = document.getElementById('themeIcon');
const label = document.getElementById('themeLabel');
if (icon && label) {
if (theme === 'light') {
icon.className = 'fa-solid fa-moon';
label.textContent = 'Dark mode';
} else {
icon.className = 'fa-solid fa-sun';
label.textContent = 'Light mode';
}
}
if (document.getElementById('panel-roadmap') && document.getElementById('panel-roadmap').classList.contains('active')) {
renderRoadmap();
}
if (withToast) {
showToast(theme === 'light' ? 'Light mode enabled' : 'Dark mode enabled');
}
}
// Save settings to local storage
function saveSettings() {
const settings = {
projectKey: document.getElementById('projectKey').value,
customFields: document.getElementById('customFields').value,
jqlQuery: document.getElementById('jqlQuery').value
};
localStorage.setItem('jira_roadmap_settings', JSON.stringify(settings));
}
// Load settings on startup
function loadSettings() {
const saved = localStorage.getItem('jira_roadmap_settings');
if (saved) {
const settings = JSON.parse(saved);
document.getElementById('projectKey').value = settings.projectKey || '';
document.getElementById('customFields').value = settings.customFields || '';
document.getElementById('jqlQuery').value = settings.jqlQuery || '';
}
}
// --- UTILITIES ---
function showToast(msg, type='success') {
const c = document.getElementById('toastContainer');
const t = document.createElement('div');
t.className = `toast toast-${type}`;
t.innerHTML = `<i class="fa-solid fa-${type==='success'?'check-circle':'xmark-circle'}"></i><span>${msg}</span>`;
c.appendChild(t);
setTimeout(() => t.remove(), 3500);
}
function autoJql() {
const key = document.getElementById('projectKey').value.trim().toUpperCase();
const jql = document.getElementById('jqlQuery');
if (!jql.value.trim() || jql.value.trim().startsWith('project =')) {
jql.value = `project = "${key}" ORDER BY created DESC`;
}
}
async function pingApi() {
saveSettings();
const btn = event.target.closest('.btn');
const orig = btn.innerHTML; btn.innerHTML = '<span class="spinner"></span> Testing...'; btn.disabled = true;
try {
const r = await fetch(`${API_BASE}/api/extract`, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({username:'_', apiToken:'_', projectKey:'_'}) });
if (r.status === 400) showToast('Local API connected!', 'success');
else showToast('API responded: ' + r.status, 'success');
} catch(e) {
showToast('API unreachable. Please run "npm start".', 'error');
} finally { btn.innerHTML = orig; btn.disabled = false; }
}
async function startExtraction() {
saveSettings();
const btn = document.getElementById('extractBtn');
const user = document.getElementById('username').value.trim();
const token = document.getElementById('apiToken').value.trim();
const project = document.getElementById('projectKey').value.trim().toUpperCase();
const jql = document.getElementById('jqlQuery').value.trim();
const custom = document.getElementById('customFields').value.trim();
if (!user || !token || !project) { showToast('Required fields missing.', 'error'); return; }
btn.innerHTML = '<span class="spinner"></span> Extracting...'; btn.disabled = true;
try {
const resp = await fetch(`${API_BASE}/api/extract`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user, apiToken: token, projectKey: project, jql: jql, customFields: custom })
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || `Error ${resp.status}`);
allTickets = data.tickets;
filteredTickets = [...allTickets];
customFieldsMap = parseCustomFields(custom);
lastUsername = user;
lastApiToken = token;
renderResults();
showToast(`${allTickets.length} tickets extracted.`, 'success');
} catch (err) {
showToast(err.message, 'error');
} finally {
btn.innerHTML = '<i class="fa-solid fa-download"></i> Start Extraction'; btn.disabled = false;
}
}
function parseCustomFields(customFields) {
const map = [];
if (customFields) {
customFields.split(',').forEach(cf => {
const [id, label] = cf.split(':').map(s => s.trim());
if (id) map.push({ id, label: label || id });
});
}
return map;
}
function sortTable(colKey) {
if (currentSortCol === colKey) {
isAsc = !isAsc;
} else {
currentSortCol = colKey;
isAsc = true;
}
filteredTickets.sort((a, b) => {
let valA = a[colKey] || '';
let valB = b[colKey] || '';
if (colKey.toLowerCase().includes('date') || colKey === 'created' || colKey === 'dueDate') {
valA = new Date(valA);
valB = new Date(valB);
}
if (valA < valB) return isAsc ? -1 : 1;
if (valA > valB) return isAsc ? 1 : -1;
return 0;
});
renderTable();
}
// --- TICKET UPDATE ---
async function updateTicket(ticketKey, dueDate, goLiveDate) {
if (!lastUsername || !lastApiToken) {
showToast('Please extract tickets first.', 'error');
return;
}
const btn = document.querySelector(`[data-ticket="${ticketKey}"]`);
const orig = btn ? btn.innerHTML : null;
if (btn) { btn.innerHTML = '<span class="spinner"></span>'; btn.disabled = true; }
const goLiveField = customFieldsMap.find(cf => cf.label.toLowerCase().includes('go live'));
const updates = {};
if (dueDate !== undefined) updates.duedate = dueDate;
if (goLiveDate !== undefined && goLiveField) updates[goLiveField.id] = goLiveDate;
try {
const resp = await fetch(`${API_BASE}/api/update`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: lastUsername, apiToken: lastApiToken, ticketKey, updates })
});
if (!resp.ok) {
const data = await resp.json();
throw new Error(data.error || `HTTP Error ${resp.status}`);
}
const ticket = allTickets.find(t => t.key === ticketKey);
if (ticket) {
if (updates.duedate !== undefined) ticket.dueDate = updates.duedate;
if (goLiveField && updates[goLiveField.id] !== undefined) ticket[goLiveField.label] = updates[goLiveField.id];
}
renderTable();
renderRoadmap();
showToast(`Ticket ${ticketKey} updated!`, 'success');
} catch (err) {
showToast(err.message, 'error');
} finally {
if (btn) { btn.innerHTML = orig; btn.disabled = false; }
}
}
function enableUpdateBtn(ticketKey) {
const btn = document.getElementById(`update-${ticketKey}`);
if (btn) btn.disabled = false;
}
function handleUpdateClick(ticketKey, goLiveColName) {
const dueInput = document.getElementById(`due-${ticketKey}`);
const dueDate = dueInput && dueInput.value ? dueInput.value : null;
let goLiveDate = null;
if (goLiveColName) {
const goLiveInput = document.getElementById(`gl-${ticketKey}`);
goLiveDate = goLiveInput && goLiveInput.value ? goLiveInput.value : null;
}
updateTicket(ticketKey, dueDate, goLiveDate);
}
// --- GANTT DATE EDIT MODAL ---
let dateEditContext = { ticketKey: null, goLiveColName: null };
// Detects the real go-live custom field name, ignoring known built-in ticket properties
function getGoLiveColumnName() {
if (!allTickets.length) return null;
return Object.keys(allTickets[0]).find(key =>
!['key','url','summary','description','status','statusCategory','assignee','reporter','priority','issueType','created','updated','dueDate','resolution','resolutionDate','labels','components','fixVersions','projectName'].includes(key)
) || null;
}
function openDateEditModal(ticketKey) {
const ticket = allTickets.find(t => t.key === ticketKey);
if (!ticket) return;
const goLiveColName = getGoLiveColumnName();
dateEditContext = { ticketKey, goLiveColName };
document.getElementById('dateEditTicketKey').textContent = ticket.key;
document.getElementById('dateEditTicketSummary').textContent = ticket.summary || '';
document.getElementById('modalDueDate').value = ticket.dueDate ? ticket.dueDate.substring(0, 10) : '';
const goLiveGroup = document.getElementById('modalGoLiveGroup');
if (goLiveColName) {
goLiveGroup.style.display = '';
document.getElementById('modalGoLiveLabel').textContent = goLiveColName;
document.getElementById('modalGoLiveDate').value = ticket[goLiveColName] ? ticket[goLiveColName].substring(0, 10) : '';
} else {
goLiveGroup.style.display = 'none';
}
document.getElementById('dateEditModal').classList.add('visible');
}
function closeDateEditModal() {
document.getElementById('dateEditModal').classList.remove('visible');
dateEditContext = { ticketKey: null, goLiveColName: null };
}
async function saveDateEditModal() {
const { ticketKey, goLiveColName } = dateEditContext;
if (!ticketKey) return;
const dueDate = document.getElementById('modalDueDate').value || null;
const goLiveDate = goLiveColName ? (document.getElementById('modalGoLiveDate').value || null) : undefined;
const btn = document.getElementById('modalSaveBtn');
const orig = btn.innerHTML;
btn.innerHTML = '<span class="spinner"></span>';
btn.disabled = true;
try {
await updateTicket(ticketKey, dueDate, goLiveDate);
closeDateEditModal();
} finally {
btn.innerHTML = orig;
btn.disabled = false;
}
}
// --- RENDERING ---
function renderResults() {
document.getElementById('results-section').classList.add('visible');
renderStats(); renderTable(); renderJSON(); renderRoadmap();
}
function renderStats() {
const unassigned = allTickets.filter(t => !t.assignee).length;
const now = new Date();
const overdue = allTickets.filter(t => t.dueDate && mapJiraStatus(t.status) !== 'Done' && new Date(t.dueDate) < now).length;
document.getElementById('statsRow').innerHTML = `
<div class="stat-card"><div class="stat-value">${allTickets.length}</div><div class="stat-label">Tickets</div></div>
<div class="stat-card"><div class="stat-value">${new Set(allTickets.map(t=>mapJiraStatus(t.status))).size}</div><div class="stat-label">Statuses</div></div>
<div class="stat-card"><div class="stat-value">${unassigned}</div><div class="stat-label">Unassigned</div></div>
<div class="stat-card"><div class="stat-value" style="color:${overdue>0?'var(--danger)':'var(--accent)'}">${overdue}</div><div class="stat-label">Overdue</div></div>
`;
}
function escHtml(s) { if(!s) return ''; const d=document.createElement('div'); d.textContent=String(s); return d.innerHTML; }
function fmtDate(d) { if(!d) return '<span class="empty-cell">-</span>'; try { return new Date(d).toLocaleDateString('en-US',{day:'2-digit',month:'short',year:'numeric'}); } catch { return escHtml(d); } }
function renderTable() {
const goLiveColName = getGoLiveColumnName();
const colMapping = {
'Ticket': 'key',
'Summary': 'summary',
'Status': 'status',
'Assignee': 'assignee',
'Priority': 'priority',
'Type': 'issueType',
'Due Date': 'dueDate'
};
const cols = ['Ticket','Summary','Status','Assignee','Priority','Type','Due Date'];
if (goLiveColName) {
cols.push(goLiveColName);
colMapping[goLiveColName] = goLiveColName;
}
cols.push('Actions');
document.getElementById('tableHead').innerHTML = '<tr>' + cols.map(c => {
const key = colMapping[c];
if (!key) return `<th>${c}</th>`;
const icon = currentSortCol === key ? (isAsc ? ' ▴' : ' ▾') : '';
return `<th onclick="sortTable('${key}')" style="cursor:pointer" class="sortable-th">
${c}${icon}
</th>`;
}).join('') + '</tr>';
let body = '';
filteredTickets.forEach(t => {
const dueDateValue = t.dueDate ? t.dueDate.substring(0, 10) : '';
const mappedStatus = mapJiraStatus(t.status);
body += `<tr>
<td><span class="ticket-key" onclick="window.open('${t.url}', '_blank')">${escHtml(t.key)}</span></td>
<td title="${escHtml(t.summary)}">${escHtml(t.summary)}</td>
<td>
<span class="status-badge ${getStatusClass(t.status)}" title="Jira: ${escHtml(t.status)}">
${escHtml(mappedStatus)}
</span>
</td>
<td>${escHtml(t.assignee) || '-'}</td>
<td>${escHtml(t.priority)}</td>
<td>${escHtml(t.issueType)}</td>
<td><input type="date" class="date-input" id="due-${t.key}" value="${dueDateValue}" onchange="enableUpdateBtn('${t.key}')"></td>`;
if (goLiveColName) {
const glVal = t[goLiveColName] ? t[goLiveColName].substring(0, 10) : '';
body += `<td><input type="date" class="date-input" id="gl-${t.key}" value="${glVal}" onchange="enableUpdateBtn('${t.key}')"></td>`;
}
body += `<td>
<button class="btn-update" id="update-${t.key}" data-ticket="${t.key}" onclick="handleUpdateClick('${t.key}', '${goLiveColName || ''}')" disabled>
<i class="fa-solid fa-rotate"></i> Update
</button>
</td></tr>`;
});
document.getElementById('tableBody').innerHTML = body || '<tr><td colspan="10">No results found</td></tr>';
}
// --- ROADMAP (GANTT) ---
function setGanttView(view) {
currentGanttView = view;
renderRoadmap();
}
function toggleGanttExpand() {
isGanttExpanded = !isGanttExpanded;
updateGanttExpandState();
}
function updateGanttExpandState() {
const container = document.querySelector('.gantt-container');
const icon = document.getElementById('ganttExpandIcon');
const label = document.getElementById('ganttExpandLabel');
const btn = document.getElementById('ganttExpandBtn');
if (container) {
if (isGanttExpanded) {
container.classList.add('expanded');
} else {
container.classList.remove('expanded');
}
}
if (icon && label) {
if (isGanttExpanded) {
icon.className = 'fa-solid fa-down-left-and-up-right-to-center';
label.textContent = 'Collapse';
if (btn) btn.classList.add('active');
} else {
icon.className = 'fa-solid fa-up-right-and-down-left-from-center';
label.textContent = 'Expand';
if (btn) btn.classList.remove('active');
}
}
}
// --- GANTT UTILITIES ---
/**
* Extracts short title and project type according to the pattern: "Title - Type - Amount - ID"
*/
function parseTicketSummary(summary) {
if (!summary) return { shortTitle: 'Untitled', type: 'OTHER' };
const parts = summary.split(' - ');
const shortTitle = parts[0].trim();
const typePart = parts[1] ? parts[1].trim().toUpperCase() : 'OTHER';
const type = ['PR', 'CR', 'TR'].includes(typePart) ? typePart : 'OTHER';
return { shortTitle, type };
}
/**
* Defines activity type accent color
*/
function getTypeColor(type) {
const isLight = document.documentElement.getAttribute('data-theme') === 'light';
switch(type) {
case 'PR': return isLight ? '#0284c7' : '#38bdf8'; // Blue
case 'CR': return isLight ? '#b45309' : '#fbbf24'; // Amber
case 'TR': return isLight ? '#6d28d9' : '#c084fc'; // Purple
default: return isLight ? '#475569' : '#94a3b8'; // Gray
}
}
/**
* MAIN ROADMAP RENDERING FUNCTION
*/
function renderRoadmap() {
const preview = document.getElementById('roadmapPreview');
if (!filteredTickets || filteredTickets.length === 0) {
preview.innerHTML = `<div style="text-align:center; padding:60px; color:var(--fg-dim);">No tickets to display.</div>`;
return;
}
const goLiveColName = Object.keys(filteredTickets[0]).find(k => k.toLowerCase().includes('go live')) || "Go Live Date";
const leftWidth = 280;
let colWidth = 140;
if (currentGanttView === 'quarter') colWidth = 200;
if (currentGanttView === 'year') colWidth = 250;
// Filter tickets with at least one date
const ticketsWithDates = filteredTickets.filter(t => {
const d1 = t.dueDate ? new Date(t.dueDate) : null;
const d2 = t[goLiveColName] ? new Date(t[goLiveColName]) : null;
return (d1 && !isNaN(d1.getTime())) || (d2 && !isNaN(d2.getTime()));
});
if (ticketsWithDates.length === 0) {
preview.innerHTML = `<div style="padding:40px; text-align:center; color:var(--fg-dim);">No Due Date or Go Live date found for filtered tickets.</div>`;
return;
}
const allDates = ticketsWithDates.flatMap(t => [
t.dueDate ? new Date(t.dueDate) : null,
t[goLiveColName] ? new Date(t[goLiveColName]) : null
].filter(d => d !== null));
let minDate = new Date(Math.min(...allDates));
let maxDate = new Date(Math.max(...allDates));
minDate.setDate(1);
if (currentGanttView === 'quarter') minDate.setMonth(Math.floor(minDate.getMonth() / 3) * 3);
if (currentGanttView === 'year') minDate.setMonth(0);
maxDate.setMonth(maxDate.getMonth() + 2);
maxDate.setDate(0);
const timeScale = [];
let tempDate = new Date(minDate);
while (tempDate <= maxDate) {
let label = "", key = "";
if (currentGanttView === 'month') {
label = tempDate.toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
key = `${tempDate.getFullYear()}-${tempDate.getMonth()}`;
tempDate.setMonth(tempDate.getMonth() + 1);
} else if (currentGanttView === 'quarter') {
const q = Math.floor(tempDate.getMonth() / 3) + 1;
label = `Q${q} ${tempDate.getFullYear()}`;
key = `${tempDate.getFullYear()}-Q${q}`;
tempDate.setMonth(tempDate.getMonth() + 3);
} else if (currentGanttView === 'year') {
label = `${tempDate.getFullYear()}`;
key = `${tempDate.getFullYear()}`;
tempDate.setFullYear(tempDate.getFullYear() + 1);
}
timeScale.push({ key, label });
}
const getXPos = (date) => {
if (!date) return 0;
const d = new Date(date);
let index = -1;
if (currentGanttView === 'month') index = timeScale.findIndex(m => m.key === `${d.getFullYear()}-${d.getMonth()}`);
else if (currentGanttView === 'quarter') index = timeScale.findIndex(m => m.key === `${d.getFullYear()}-Q${Math.floor(d.getMonth() / 3) + 1}`);
else if (currentGanttView === 'year') index = timeScale.findIndex(m => m.key === `${d.getFullYear()}`);
if (index === -1) return 0;
let ratio = 0;
if (currentGanttView === 'month') ratio = (d.getDate() - 1) / 30;
else if (currentGanttView === 'quarter') ratio = ((d.getMonth() % 3) * 30 + d.getDate()) / 90;
else if (currentGanttView === 'year') ratio = (d.getMonth() * 30 + d.getDate()) / 365;
return leftWidth + (index * colWidth) + (ratio * colWidth);
};
const grouped = ticketsWithDates.reduce((acc, t) => {
const name = t.assignee || "Unassigned";
if (!acc[name]) acc[name] = [];
acc[name].push(t);
return acc;
}, {});
let html = `<div class="gantt-container ${isGanttExpanded ? 'expanded' : ''}"><div class="gantt-canvas" style="width: ${leftWidth + (timeScale.length * colWidth)}px;">`;
const todayX = getXPos(new Date());
if (todayX > leftWidth) {
html += `<div class="gantt-today-line" style="left: ${todayX}px;"></div>`;
html += `<div class="gantt-today-label" id="todayMarker" style="left: ${todayX}px;">Today</div>`;
}
html += `<div class="gantt-header"><div class="gantt-sticky-col gantt-group-name" style="width:${leftWidth}px">Assignee / Ticket (Title)</div>`;
timeScale.forEach(ts => { html += `<div class="gantt-month-header" style="width:${colWidth}px;">${ts.label}</div>`; });
html += `</div>`;
Object.keys(grouped).sort().forEach(assignee => {
html += `<div class="gantt-group-header">
<div class="gantt-sticky-col gantt-group-name" style="width:${leftWidth}px">
<i class="fa-solid fa-user-circle mr-8"></i>${assignee}
</div>
</div>`;
const sortedTickets = grouped[assignee].sort((a, b) => (new Date(a.dueDate || 0)) - (new Date(b.dueDate || 0)));
sortedTickets.forEach(t => {
const dDue = t.dueDate ? new Date(t.dueDate) : null;
const dLive = t[goLiveColName] ? new Date(t[goLiveColName]) : null;
const start = dDue || dLive;
const end = dLive || dDue;
const x1 = getXPos(start);
const x2 = getXPos(end);
const barWidth = Math.max(170, (x2 - x1) + 10);
const { shortTitle, type } = parseTicketSummary(t.summary);
const mappedStatus = mapJiraStatus(t.status);
const statusCfg = getStatusThemeConfig(mappedStatus);
const typeColor = getTypeColor(type);
html += `<div class="gantt-row">
<div class="gantt-sticky-col gantt-ticket-id" style="width:${leftWidth}px;" title="${escHtml(t.key)}: ${escHtml(t.summary)}">
<button class="gantt-date-edit-btn" onclick="event.stopPropagation(); openDateEditModal('${t.key}')" title="Edit dates">
<i class="fa-solid fa-calendar-days"></i>
</button>
<span class="gantt-ticket-id-label">${escHtml(shortTitle)}</span>
</div>
<div class="gantt-bar"
onclick="window.open('${t.url}', '_blank')"
title="${escHtml(t.key)}: ${escHtml(t.summary)} | Status: ${escHtml(t.status)} (${mappedStatus}) | Type: ${type}"
style="left:${x1}px; width:${barWidth}px; background:${statusCfg.bg}; border-color:${statusCfg.border}; border-left:5px solid ${statusCfg.border};">
<div class="gantt-status-badge" style="background:${statusCfg.badgeBg}; color:${statusCfg.badgeText};">
${escHtml(mappedStatus)}
</div>
<span class="gantt-type-tag" style="background:${typeColor}20; color:${typeColor}; border:1px solid ${typeColor}66;">
${type}
</span>
<span class="gantt-bar-title">${escHtml(shortTitle)}</span>
</div>
${timeScale.map(() => `<div class="gantt-grid-col" style="width:${colWidth}px;"></div>`).join('')}
</div>`;
});
});
html += `</div></div>`;
preview.innerHTML = html;
setTimeout(() => {
const m = document.getElementById('todayMarker');
if (m) m.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
}, 150);
}
// --- TAB & FILTER ---
function switchTab(id, btn) {
document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active'));
btn.classList.add('active'); document.getElementById(`panel-${id}`).classList.add('active');
if(id === 'roadmap') renderRoadmap();
}
function filterTable() {
const q = document.getElementById('searchInput').value.toLowerCase();
filteredTickets = q ? allTickets.filter(t => Object.values(t).some(v => v && String(v).toLowerCase().includes(q))) : [...allTickets];
renderTable();
if(document.getElementById('panel-roadmap').classList.contains('active')) renderRoadmap();
}
function exportDrawIOXML(mode) {
try {
if (!filteredTickets || filteredTickets.length === 0) {
showToast("No tickets to export", "error");
return;
}
const goLiveCol = Object.keys(filteredTickets[0]).find(k => k.toLowerCase().includes('go live')) || "dueDate";
const ticketsWithDates = filteredTickets.filter(t => t.dueDate || t[goLiveCol]);
if (ticketsWithDates.length === 0) {
showToast("No end dates found", "error");
return;
}
const leftWidth = 250;
const rowHeight = 50;
let pixelPerDay = 12;
if (currentGanttView === 'quarter') pixelPerDay = 4;
if (currentGanttView === 'year') pixelPerDay = 1.2;
const allDates = ticketsWithDates.map(t => new Date(t.dueDate || t[goLiveCol]));
let minDate = new Date(Math.min(...allDates));
minDate.setDate(1);
let maxDate = new Date(Math.max(...allDates));
maxDate.setMonth(maxDate.getMonth() + 4);
const getX = (dateStr) => {
const d = new Date(dateStr);
if (isNaN(d.getTime())) return leftWidth;
return Math.floor(leftWidth + ((d - minDate) / 86400000 * pixelPerDay));
};
let xmlNodes = '';
let curr = new Date(minDate);
while (curr < maxDate) {
const xPos = getX(curr);
let label = "";
if (currentGanttView === 'month') {
label = curr.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }).toUpperCase();
curr.setMonth(curr.getMonth() + 1);
} else if (currentGanttView === 'quarter') {
label = "Q" + (Math.floor(curr.getMonth() / 3) + 1) + " " + curr.getFullYear();
curr.setMonth(curr.getMonth() + 3);
} else {
label = curr.getFullYear();
curr.setFullYear(curr.getFullYear() + 1);
}
xmlNodes += `<mxCell id="m_${curr.getTime()}" value="${label}" style="text;fontColor=#7a9488;fontSize=14;fontStyle=1;align=left;verticalAlign=middle" vertex="1" parent="1">
<mxGeometry x="${xPos + 5}" y="5" width="150" height="30" as="geometry"/>
</mxCell>`;
xmlNodes += `<mxCell id="l_${curr.getTime()}" value="" style="line;strokeColor=#E0E0E0;direction=south;html=1;opacity=20" vertex="1" parent="1">
<mxGeometry x="${xPos}" y="0" width="10" height="4000" as="geometry"/>
</mxCell>`;
}
const xToday = getX(new Date());
xmlNodes += `<mxCell id="today" value="TODAY" style="line;strokeColor=#FF0000;direction=south;html=1;dashed=1;strokeWidth=2;fontColor=#FF0000;fontSize=12;fontStyle=1;verticalAlign=top" vertex="1" parent="1">
<mxGeometry x="${xToday}" y="0" width="10" height="4000" as="geometry"/>
</mxCell>`;
let currentY = 80;
const groups = ticketsWithDates.reduce((acc, t) => {
const a = t.assignee || 'Unassigned';
if (!acc[a]) acc[a] = [];
acc[a].push(t);
return acc;
}, {});
Object.keys(groups).sort().forEach(assignee => {
xmlNodes += `<mxCell id="as_${currentY}" value="${assignee.toUpperCase()}" style="text;fontColor=#00e68a;fontStyle=1;fontSize=14" vertex="1" parent="1">
<mxGeometry x="10" y="${currentY}" width="200" height="30" as="geometry"/>
</mxCell>`;
currentY += 40;
groups[assignee].forEach(t => {
const { shortTitle, type } = parseTicketSummary(t.summary);
const mappedStatus = mapJiraStatus(t.status);
const statusDef = STATUS_DEFINITIONS[mappedStatus] || STATUS_DEFINITIONS['Opportunity'];
const barColor = statusDef.drawioColor;
const startDate = t.created ? new Date(t.created) : minDate;
const endDate = new Date(t.dueDate || t[goLiveCol]);
const xStart = getX(startDate);
const xEnd = getX(endDate);
let barWidth = xEnd - xStart;
if (barWidth < 50) barWidth = 140;
xmlNodes += `<mxCell id="lbl_${currentY}" value="${escHtml(shortTitle)}" style="text;fontColor=#555555;fontSize=10;align=left;whiteSpace=wrap" vertex="1" parent="1">
<mxGeometry x="30" y="${currentY}" width="200" height="30" as="geometry"/>
</mxCell>`;
const label = `[${mappedStatus}] [${type}] ${escHtml(shortTitle)}`;
xmlNodes += `<mxCell id="bar_${currentY}" value="${label}" style="rounded=1;fillColor=${barColor};strokeColor=#FFFFFF;fontColor=#FFFFFF;fontSize=10;fontStyle=1;spacingLeft=8;align=left" vertex="1" parent="1">
<mxGeometry x="${xStart}" y="${currentY}" width="${barWidth}" height="28" as="geometry"/>
</mxCell>`;
currentY += rowHeight;
});
currentY += 20;
});
const finalXml = `<?xml version="1.0" encoding="UTF-8"?>
<mxfile host="app.diagrams.net">
<diagram id="Jira" name="Roadmap">
<mxGraphModel dx="1000" dy="1000" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="0" background="none">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
${xmlNodes}
</root>
</mxGraphModel>
</diagram>
</mxfile>`;
if (mode === 'download') {
const blob = new Blob([finalXml], { type: 'text/xml' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = "jira_export.drawio";
a.click();
} else {
navigator.clipboard.writeText(finalXml).then(() => showToast("XML Copied!"));
}
} catch (error) {
console.error(error);
showToast("Error during XML export", "error");
}
}
function renderJSON() { document.getElementById('jsonViewer').innerHTML = syntaxHL(JSON.stringify(allTickets, null, 2)); }
function syntaxHL(json) {
json = json.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
return json.replace(/("(\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, m => {
let c = 'json-number'; if(/^"/.test(m)) c = /:$/.test(m)?'json-key':'json-string'; else if(/true|false/.test(m)) c='json-bool'; else if(/null/.test(m)) c='json-null';
return `<span class="${c}">${m}</span>`;
});
}
function downloadJSON() {
const blob = new Blob([JSON.stringify(allTickets, null, 2)], {type:'application/json'});
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
a.download = `export-${Date.now()}.json`; a.click();
}
function copyJSON() { navigator.clipboard.writeText(JSON.stringify(allTickets, null, 2)).then(() => showToast('Copied!')); }
function clearResults() { allTickets=[]; filteredTickets=[]; document.getElementById('results-section').classList.remove('visible'); }
document.addEventListener('keydown', e => {
if((e.ctrlKey||e.metaKey)&&e.key==='Enter'){e.preventDefault();startExtraction()}
if(e.key === 'Escape') closeDateEditModal();
});
document.addEventListener('DOMContentLoaded', () => {
initTheme();
loadSettings();
const modalOverlay = document.getElementById('dateEditModal');
if (modalOverlay) {
modalOverlay.addEventListener('click', e => { if (e.target === modalOverlay) closeDateEditModal(); });
}
console.log("Settings loaded from local storage.");
});