// Sprint view: Backlog + Sprint board + Burndown + Story points + Review modal + Velocity
const SPIcons = window.Icons;
const { COLUMNS: SP_COLS, parseDateKey: spDate, relativeDate: spRel, isOverdue: spOver } = window.PMStore;
const { Tag: SPTag, Priority: SPPriority } = window.UI;
const SP_POINTS = [1, 2, 3, 5, 8, 13];
const PRIORITY_ORDER = { high: 0, medium: 1, low: 2 };
function SprintView({ state, onSelectTask, onUpdateTask, onAddTask,
onAddSprint, onUpdateSprint, onDeleteSprint, onCloseSprint, onMoveToSprint }) {
const sprints = (state.sprints || []).filter(s => s.projectId === state.activeProjectId);
const activeSprint = sprints.find(s => s.status === 'active') || sprints.find(s => s.status === 'planning') || null;
const [selectedSprintId, setSelectedSprintId] = React.useState(() => activeSprint?.id || null);
const [showCreate, setShowCreate] = React.useState(false);
const [showEdit, setShowEdit] = React.useState(false);
const [showReview, setShowReview] = React.useState(false);
const [dragTaskId, setDragTaskId] = React.useState(null);
const [dragTarget, setDragTarget] = React.useState(null);
const [backlogSort, setBacklogSort] = React.useState('order');
React.useEffect(() => {
const active = sprints.find(s => s.status === 'active') || sprints.find(s => s.status === 'planning');
if (active && !selectedSprintId) setSelectedSprintId(active.id);
}, [state.activeProjectId]);
const sprint = sprints.find(s => s.id === selectedSprintId) || null;
const projectTasks = state.tasks.filter(t => t.projectId === state.activeProjectId && !t.archived);
const backlogTasksRaw = projectTasks.filter(t => !t.sprintId);
const backlogTasks = [...backlogTasksRaw].sort((a, b) => {
if (backlogSort === 'priority') return (PRIORITY_ORDER[a.priority] ?? 1) - (PRIORITY_ORDER[b.priority] ?? 1);
if (backlogSort === 'due') {
if (!a.due && !b.due) return 0;
if (!a.due) return 1;
if (!b.due) return -1;
return spDate(a.due) - spDate(b.due);
}
return (a.order ?? 0) - (b.order ?? 0);
});
const sprintTasks = sprint ? projectTasks.filter(t => t.sprintId === sprint.id) : [];
const usePoints = sprintTasks.some(t => t.storyPoints);
const plannedPts = sprintTasks.reduce((s, t) => s + (t.storyPoints || 0), 0);
const donePts = sprintTasks.filter(t => t.status === 'done').reduce((s, t) => s + (t.storyPoints || 0), 0);
const capacity = sprint?.capacity || 0;
const completedSprints = sprints.filter(s => s.status === 'completed' && s.velocityPoints != null).slice(-6);
const handleDrop = (target) => {
if (!dragTaskId) return;
const spId = target === 'backlog' ? null : target;
onMoveToSprint ? onMoveToSprint(dragTaskId, spId) : onUpdateTask(dragTaskId, { sprintId: spId });
setDragTaskId(null);
setDragTarget(null);
};
const handleMoveTask = (taskId, spId) => {
onMoveToSprint ? onMoveToSprint(taskId, spId) : onUpdateTask(taskId, { sprintId: spId });
};
const handleStartSprint = () => onUpdateSprint(sprint.id, { status: 'active' });
const handleReviewConfirm = ({ taskDisposition, velocityPoints }) => {
onCloseSprint(sprint.id, { velocityPoints, taskDisposition });
setShowReview(false);
setSelectedSprintId(null);
};
return (
{/* ── Sprint seçici + meta ─────────────────────────────── */}
{sprint && sprint.status !== 'completed' && (
)}
{sprint && (
{sprint.status === 'planning' ? 'Planlama' : sprint.status === 'active' ? 'Aktif' : 'Tamamlandı'}
{sprint.startDate && sprint.endDate && (
{sprint.startDate} → {sprint.endDate}
)}
{sprint.goal &&
"{sprint.goal}"}
{capacity > 0 && (
capacity ? 'var(--p-high)' : undefined }}>
{plannedPts}/{capacity} SP
)}
{!capacity && usePoints && (
{plannedPts} SP · {donePts} tamamlandı
)}
{sprint.status === 'planning' && (
)}
{sprint.status === 'active' && (
)}
)}
{/* ── Burndown + velocity ──────────────────────────────── */}
{sprint && sprint.startDate && sprint.endDate && (
Burndown
{completedSprints.length > 0 &&
}
)}
{/* ── Backlog | Sprint board ───────────────────────────── */}
{/* Backlog */}
{ e.preventDefault(); setDragTarget('backlog'); }}
onDragLeave={() => setDragTarget(null)}
onDrop={() => handleDrop('backlog')}
>
Backlog
{backlogTasks.length}
{backlogTasks.length === 0 &&
Backlog boş
}
{backlogTasks.map(t => (
onSelectTask(t.id)}
onDragStart={() => setDragTaskId(t.id)}
onDragEnd={() => { setDragTaskId(null); setDragTarget(null); }}
onMoveToSprint={sprint ? () => handleMoveTask(t.id, sprint.id) : null}
onUpdatePoints={(pts) => onUpdateTask(t.id, { storyPoints: pts })}
/>
))}
{/* Sprint */}
{ e.preventDefault(); setDragTarget(sprint?.id || 'sprint'); }}
onDragLeave={() => setDragTarget(null)}
onDrop={() => sprint && handleDrop(sprint.id)}
>
{sprint?.name || 'Sprint'}
{sprintTasks.length}
{sprint && (
)}
{!sprint ? (
Sprint seçin veya oluşturun
) : (
{sprintTasks.length === 0 && (
Sprint boş — backlog'dan görev sürükleyin
)}
{SP_COLS.map(col => {
const colTasks = sprintTasks.filter(t => t.status === col.id);
if (!colTasks.length) return null;
return (
{col.label} {colTasks.length}
{colTasks.map(t => (
onSelectTask(t.id)}
onDragStart={() => setDragTaskId(t.id)}
onDragEnd={() => { setDragTaskId(null); setDragTarget(null); }}
onMoveToSprint={() => handleMoveTask(t.id, null)}
moveLabel="Backlog'a taşı"
onUpdatePoints={(pts) => onUpdateTask(t.id, { storyPoints: pts })}
/>
))}
);
})}
)}
{/* ── Modals ──────────────────────────────────────────── */}
{showCreate && (
{ onAddSprint(data); setShowCreate(false); }}
onClose={() => setShowCreate(false)}
/>
)}
{showEdit && sprint && (
{ onUpdateSprint(sprint.id, data); setShowEdit(false); }}
onClose={() => setShowEdit(false)}
onDelete={() => { onDeleteSprint(sprint.id); setSelectedSprintId(null); setShowEdit(false); }}
onCloseSprint={sprint.status === 'active' ? () => { setShowEdit(false); setShowReview(true); } : null}
/>
)}
{showReview && sprint && (
s.id !== sprint.id && s.status !== 'completed')}
onConfirm={handleReviewConfirm}
onClose={() => setShowReview(false)}
/>
)}
);
}
/* ── Sprint istatistik satırı ──────────────────────────────── */
function SprintStats({ tasks, usePoints }) {
const done = tasks.filter(t => t.status === 'done').length;
const total = tasks.length;
const pct = total ? Math.round(done / total * 100) : 0;
const donePts = tasks.filter(t => t.status === 'done').reduce((s, t) => s + (t.storyPoints || 0), 0);
const totalPts = tasks.reduce((s, t) => s + (t.storyPoints || 0), 0);
return (
{done}/{total} görev
{usePoints && {donePts}/{totalPts} SP}
{pct}%
);
}
/* ── Burndown grafiği ──────────────────────────────────────── */
function BurndownChart({ sprint, tasks, usePoints }) {
const start = new Date(sprint.startDate + 'T00:00:00');
const end = new Date(sprint.endDate + 'T23:59:59');
const totalDays = Math.max(1, Math.round((end - start) / 86400000));
const val = (t) => usePoints ? (t.storyPoints || 1) : 1;
const totalValue = tasks.reduce((s, t) => s + val(t), 0);
if (!totalValue) return (
Sprint'e görev eklendikçe burndown grafiği görünecek.
);
const today = new Date();
const points = [];
for (let d = 0; d <= totalDays; d++) {
const dayEnd = new Date(start);
dayEnd.setDate(dayEnd.getDate() + d);
dayEnd.setHours(23, 59, 59);
const remaining = tasks.reduce((s, t) => {
if (!t.completedAt) return s + val(t);
return new Date(t.completedAt) > dayEnd ? s + val(t) : s;
}, 0);
points.push({ d, remaining });
}
const W = 500, H = 150, pL = 32, pR = 12, pT = 8, pB = 24;
const cW = W - pL - pR, cH = H - pT - pB;
const x = (d) => pL + (d / totalDays) * cW;
const y = (v) => pT + cH - (v / totalValue) * cH;
const todayDay = Math.min(totalDays, Math.max(0, Math.round((today - start) / 86400000)));
const idealPath = `M ${x(0)} ${y(totalValue)} L ${x(totalDays)} ${y(0)}`;
const actualPts = points.filter(p => p.d <= todayDay);
const actualPath = actualPts.map((p, i) => `${i === 0 ? 'M' : 'L'} ${x(p.d)} ${y(p.remaining)}`).join(' ');
return (
);
}
/* ── Velocity çubuk grafiği ────────────────────────────────── */
function VelocityBar({ sprints }) {
const max = Math.max(...sprints.map(s => s.velocityPoints || 0), 1);
const avg = Math.round(sprints.reduce((s, sp) => s + sp.velocityPoints, 0) / sprints.length);
return (
Sprint hızı
Ort. {avg}
{sprints.map(s => (
{s.velocityPoints}
{s.name.length > 7 ? s.name.slice(0, 7) + '…' : s.name}
))}
);
}
/* ── Sprint görev kartı ────────────────────────────────────── */
function SprintTaskCard({ task, state, onSelect, onDragStart, onDragEnd, onMoveToSprint, moveLabel, onUpdatePoints }) {
const tagById = Object.fromEntries(state.tags.map(t => [t.id, t]));
const taskTags = (task.tags || []).map(id => tagById[id]).filter(Boolean);
const overdue = spOver(task.due, task.status);
const [showPts, setShowPts] = React.useState(false);
const ptsRef = React.useRef(null);
React.useEffect(() => {
if (!showPts) return;
const close = (e) => {
if (ptsRef.current && !ptsRef.current.contains(e.target)) setShowPts(false);
};
document.addEventListener('mousedown', close);
return () => document.removeEventListener('mousedown', close);
}, [showPts]);
return (
{task.title}
{ e.stopPropagation(); setShowPts(v => !v); }}>
{task.storyPoints ?? '?'}
{showPts && (
{SP_POINTS.map(p => (
))}
)}
{taskTags.length > 0 && (
{taskTags.slice(0, 3).map(tag => )}
)}
{task.due && (
{spRel(task.due)}
)}
{onMoveToSprint && (
)}
);
}
/* ── Sprint oluştur / düzenle modal ───────────────────────── */
function SprintModal({ title, initial, projectId, onSave, onClose, onDelete, onCloseSprint }) {
const [name, setName] = React.useState(initial?.name || '');
const [goal, setGoal] = React.useState(initial?.goal || '');
const [startDate, setStart] = React.useState(initial?.startDate || '');
const [endDate, setEnd] = React.useState(initial?.endDate || '');
const [capacity, setCap] = React.useState(initial?.capacity || '');
const save = () => {
if (!name.trim()) return;
onSave({
projectId,
name: name.trim(),
goal: goal.trim(),
startDate: startDate || null,
endDate: endDate || null,
capacity: capacity ? parseInt(capacity, 10) : null,
});
};
return (
e.stopPropagation()}>
{title}
{onDelete && (
)}
{onCloseSprint && (
)}
);
}
/* ── Sprint kapanış review modal ──────────────────────────── */
function SprintReviewModal({ sprint, tasks, nextSprints, onConfirm, onClose }) {
const done = tasks.filter(t => t.status === 'done');
const unfinished = tasks.filter(t => t.status !== 'done');
const usePoints = tasks.some(t => t.storyPoints);
const donePts = done.reduce((s, t) => s + (t.storyPoints || 0), 0);
const totalPts = tasks.reduce((s, t) => s + (t.storyPoints || 0), 0);
const velocity = usePoints ? donePts : done.length;
const [disposition, setDisposition] = React.useState(
() => Object.fromEntries(unfinished.map(t => [t.id, 'backlog']))
);
const allToBacklog = () =>
setDisposition(Object.fromEntries(unfinished.map(t => [t.id, 'backlog'])));
const allRollover = (spId) =>
setDisposition(Object.fromEntries(unfinished.map(t => [t.id, spId])));
return (
e.stopPropagation()}>
Sprint Kapanışı · {sprint.name}
{/* Özet istatistikler */}
{done.length}
Tamamlandı
{unfinished.length}
Tamamlanmadı
{usePoints && (
{donePts}/{totalPts}
Story point
)}
{velocity}
{usePoints ? 'SP hızı' : 'Görev hızı'}
{/* Tamamlanmayan görevler */}
{unfinished.length > 0 && (
Tamamlanmayan görevler
{nextSprints.slice(0, 1).map(ns => (
))}
{unfinished.map(t => (
{t.title}
{t.storyPoints &&
{t.storyPoints}}
{nextSprints.map(ns => (
))}
))}
)}
);
}
window.SprintView = SprintView;