/* global jQuery */ /** * Sprint settings page: JSON mutation plumbing + jQuery UI sortable wiring. * * The settings page mounts a single root element with `data-sprint-id` and * `data-csrf`. Everything below scopes itself to that root. */ (function ($) { 'use strict'; const $root = $('[data-sprint-root]'); if ($root.length === 0) { return; } const sprintId = parseInt($root.data('sprint-id'), 10); const csrf = String($root.data('csrf') || ''); // --------------------------------------------------------------------- // AJAX plumbing // --------------------------------------------------------------------- function request(method, url, body) { const opts = { method, headers: { 'Accept': 'application/json', 'X-CSRF-Token': csrf, }, credentials: 'same-origin', }; if (body !== undefined) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } return fetch(url, opts).then(async function (res) { let payload = null; try { payload = await res.json(); } catch (_) { /* ignore */ } if (!res.ok || !payload || payload.ok !== true) { const msg = (payload && payload.error && payload.error.message) ? payload.error.message : res.statusText || 'Request failed'; const err = new Error(msg); err.status = res.status; err.payload = payload; throw err; } return payload.data; }); } // --------------------------------------------------------------------- // Toast / status line // --------------------------------------------------------------------- const $status = $root.find('[data-status]'); let statusTimer = null; function flash(text, isError) { $status .text(text) .removeClass('text-green-700 text-red-700 bg-green-50 bg-red-50 border-green-200 border-red-200') .addClass(isError ? 'text-red-700 bg-red-50 border-red-200' : 'text-green-700 bg-green-50 border-green-200') .removeClass('opacity-0') .addClass('opacity-100'); clearTimeout(statusTimer); statusTimer = setTimeout(function () { $status.removeClass('opacity-100').addClass('opacity-0'); }, 2500); } // --------------------------------------------------------------------- // Sprint meta — save on change / blur // --------------------------------------------------------------------- function patchMeta(payload) { return request('PATCH', '/sprints/' + sprintId, payload) .then(function () { flash('Saved'); }) .catch(function (e) { flash(e.message, true); }); } const metaDebounce = {}; function debouncedMeta(field, value, ms) { clearTimeout(metaDebounce[field]); metaDebounce[field] = setTimeout(function () { const payload = {}; payload[field] = value; patchMeta(payload); }, ms || 400); } $root.find('[data-meta]').on('change', function () { const $el = $(this); const field = $el.attr('name'); let v = $el.val(); if (field === 'reserve_fraction') { v = Number(v) / 100; // form shows percent } debouncedMeta(field, v, 0); }); // --------------------------------------------------------------------- // Weeks count // --------------------------------------------------------------------- $root.find('[data-weeks-form]').on('submit', function (ev) { ev.preventDefault(); const n = parseInt($(this).find('input[name="n_weeks"]').val(), 10); if (!Number.isInteger(n) || n < 1) { flash('Weeks must be a positive integer', true); return; } request('POST', '/sprints/' + sprintId + '/weeks', { n_weeks: n }) .then(function () { window.location.reload(); }) .catch(function (e) { flash(e.message, true); }); }); // --------------------------------------------------------------------- // Per-week weekday checkboxes (Phase 12) // // Each row carries five [data-day-toggle] boxes. On any change we rebuild // the row's mask from all five and send it in one PATCH — no debounce on // per-checkbox granularity (each click is one state change), but we do // delay per-row in case the user ticks several in quick succession. // --------------------------------------------------------------------- const weekDebounce = {}; function maskFromRow($row) { let mask = 0; $row.find('[data-day-toggle]').each(function () { if ($(this).is(':checked')) { const bit = parseInt($(this).data('bit'), 10); if (Number.isInteger(bit)) { mask |= (1 << bit); } } }); return mask; } function popcount5(mask) { let n = 0; for (let i = 0; i < 5; i++) { if ((mask >> i) & 1) { n++; } } return n; } $root.on('change', '[data-day-toggle]', function () { const $row = $(this).closest('[data-week-row]'); const weekId = parseInt($row.data('week-id'), 10); const mask = maskFromRow($row); // Optimistic local update: derived count flips immediately. $row.find('[data-week-count]').text(String(popcount5(mask))); clearTimeout(weekDebounce[weekId]); weekDebounce[weekId] = setTimeout(function () { request('PATCH', '/sprints/' + sprintId + '/week/' + weekId, { active_days_mask: mask }) .then(function (data) { if (data && data.sprint_week) { $row.find('[data-week-count]') .text(String(data.sprint_week.max_working_days)); } flash('Saved'); }) .catch(function (e) { flash(e.message, true); }); }, 250); }); // --------------------------------------------------------------------- // Worker picker // --------------------------------------------------------------------- function workerRowTemplate(sw) { const nameSafe = $('