1085 lines
47 KiB
JavaScript
1085 lines
47 KiB
JavaScript
// ── Sessions page logic ────────────────────────────────────────────────────────
|
||
|
||
let currentType = 'prs';
|
||
let sessions = [];
|
||
let currentSession = null;
|
||
let _rigCache = {};
|
||
let _gpModalData = {};
|
||
let _analysisCache = null; // { analysisId, groups, groupPhotos }
|
||
let _openModalGpId = null;
|
||
let _sessionPhotoModal = null;
|
||
let _sessionPhotoGroupId = null;
|
||
let _sessionPhotoGpId = null; // existing GroupPhoto id when replacing
|
||
|
||
const TYPE_PATHS = {
|
||
'prs': '/sessions/prs/',
|
||
'free-practice': '/sessions/free-practice/',
|
||
'speed-shooting':'/sessions/speed-shooting/',
|
||
};
|
||
|
||
// ── Unit selector ─────────────────────────────────────────────────────────────
|
||
// fVel, fDist, getDistUnit, getVelUnit, setDistUnit, setVelUnit → utils.js
|
||
|
||
function renderUnitSelector() {
|
||
const vel = getVelUnit();
|
||
const dist = getDistUnit();
|
||
return `
|
||
<div class="d-flex align-items-center gap-2 ms-auto">
|
||
<div class="btn-group btn-group-sm" role="group" aria-label="Velocity unit">
|
||
${['fps','mps'].map(u => `
|
||
<button type="button"
|
||
class="btn btn-outline-secondary${vel===u?' active':''}"
|
||
onclick="applyUnits('vel','${u}')">${u}</button>`).join('')}
|
||
</div>
|
||
<div class="btn-group btn-group-sm" role="group" aria-label="Distance unit">
|
||
${['mm','moa','mrad'].map(u => `
|
||
<button type="button"
|
||
class="btn btn-outline-secondary${dist===u?' active':''}"
|
||
onclick="applyUnits('dist','${u}')">${u.toUpperCase()}</button>`).join('')}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function applyUnits(key, val) {
|
||
if (key === 'vel') setVelUnit(val); else setDistUnit(val);
|
||
const wrap = document.getElementById('unitSelectorWrap');
|
||
if (wrap) wrap.innerHTML = renderUnitSelector();
|
||
// Re-render from cache — no API call needed
|
||
if (_analysisCache) renderAnalysisGroupsFromCache();
|
||
// Sync the modal's unit buttons and stats if the modal is open
|
||
const modalEl = document.getElementById('groupPhotoModal');
|
||
applyDistUnitButtons(modalEl);
|
||
if (modalEl?.classList.contains('show') && _openModalGpId != null)
|
||
renderGroupPhotoModalStats(_openModalGpId);
|
||
}
|
||
|
||
// Unit switch inside the group photo modal
|
||
document.getElementById('groupPhotoModal')?.addEventListener('click', e => {
|
||
const btn = e.target.closest('[data-dist-unit]');
|
||
if (!btn) return;
|
||
applyUnits('dist', btn.dataset.distUnit);
|
||
});
|
||
|
||
// showToast → utils.js
|
||
|
||
// ── Tab switching ─────────────────────────────────────────────────────────────
|
||
|
||
document.getElementById('sessionTabs').addEventListener('click', e => {
|
||
const link = e.target.closest('[data-type]');
|
||
if (!link) return;
|
||
e.preventDefault();
|
||
document.querySelectorAll('#sessionTabs .nav-link').forEach(l => l.classList.remove('active'));
|
||
link.classList.add('active');
|
||
currentType = link.dataset.type;
|
||
currentSession = null;
|
||
document.getElementById('noSessionMsg').classList.remove('d-none');
|
||
document.getElementById('sessionDetail').classList.add('d-none');
|
||
loadSessions();
|
||
});
|
||
|
||
// ── Load sessions list ────────────────────────────────────────────────────────
|
||
|
||
async function loadSessions() {
|
||
document.getElementById('listSpinner').classList.remove('d-none');
|
||
document.getElementById('listEmpty').classList.add('d-none');
|
||
document.getElementById('sessionsList').innerHTML = '';
|
||
try {
|
||
sessions = await apiGetAll(TYPE_PATHS[currentType]);
|
||
renderList();
|
||
} catch(e) {
|
||
document.getElementById('listSpinner').innerHTML =
|
||
'<p class="text-danger small">Failed to load sessions.</p>';
|
||
}
|
||
}
|
||
|
||
function renderList() {
|
||
document.getElementById('listSpinner').classList.add('d-none');
|
||
const list = document.getElementById('sessionsList');
|
||
const empty = document.getElementById('listEmpty');
|
||
if (!sessions.length) { empty.classList.remove('d-none'); list.innerHTML = ''; return; }
|
||
empty.classList.add('d-none');
|
||
list.innerHTML = sessions.map(s => {
|
||
const label = s.competition_name || s.name || '(unnamed)';
|
||
const sub = s.date || '';
|
||
const active = currentSession && currentSession.id === s.id ? 'active' : '';
|
||
return `
|
||
<div class="session-item mb-1 ${active}" onclick="selectSession(${s.id})">
|
||
<div class="fw-semibold small">${esc(label)}</div>
|
||
<div class="text-muted small">${esc(sub)}</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
// ── Select & display session ──────────────────────────────────────────────────
|
||
|
||
async function selectSession(id) {
|
||
document.getElementById('noSessionMsg').classList.add('d-none');
|
||
document.getElementById('sessionDetail').classList.add('d-none');
|
||
_analysisCache = null;
|
||
|
||
try {
|
||
currentSession = await apiGet(`${TYPE_PATHS[currentType]}${id}/`);
|
||
renderList();
|
||
renderDetail();
|
||
document.getElementById('sessionDetail').classList.remove('d-none');
|
||
} catch(e) {
|
||
showToast('Failed to load session.', 'danger');
|
||
}
|
||
}
|
||
|
||
function renderDetail() {
|
||
const s = currentSession;
|
||
const titleLabel = s.competition_name || s.name || '(unnamed)';
|
||
document.getElementById('detailTitle').textContent = titleLabel;
|
||
document.getElementById('detailMeta').textContent =
|
||
[s.date, s.location].filter(Boolean).join(' · ');
|
||
|
||
// Meta card
|
||
let metaHtml = '';
|
||
if (currentType === 'prs') {
|
||
metaHtml = metaRow([
|
||
[t('sessions.meta.competition'), s.competition_name],
|
||
[t('sessions.meta.category'), s.category],
|
||
[t('sessions.meta.date'), s.date],
|
||
[t('sessions.meta.location'), s.location],
|
||
[t('sessions.meta.rig'), s.rig_detail ? s.rig_detail.name : null],
|
||
[t('sessions.meta.ammo'), ammoLabel(s)],
|
||
]);
|
||
} else if (currentType === 'free-practice') {
|
||
metaHtml = metaRow([
|
||
[t('sessions.meta.date'), s.date],
|
||
[t('sessions.meta.location'), s.location],
|
||
[t('sessions.meta.distance'), s.distance_m ? s.distance_m + ' m' : null],
|
||
[t('sessions.meta.target'), s.target_description],
|
||
[t('sessions.meta.rounds'), s.rounds_fired],
|
||
[t('sessions.meta.rig'), s.rig_detail ? s.rig_detail.name : null],
|
||
[t('sessions.meta.ammo'), ammoLabel(s)],
|
||
]);
|
||
} else {
|
||
metaHtml = metaRow([
|
||
[t('sessions.meta.date'), s.date],
|
||
[t('sessions.meta.location'), s.location],
|
||
[t('sessions.meta.format'), s.format],
|
||
[t('sessions.meta.rounds'), s.rounds_fired],
|
||
[t('sessions.meta.rig'), s.rig_detail ? s.rig_detail.name : null],
|
||
[t('sessions.meta.ammo'), ammoLabel(s)],
|
||
]);
|
||
}
|
||
document.getElementById('metaFields').innerHTML = metaHtml;
|
||
|
||
// Weather fields
|
||
document.getElementById('weatherFields').innerHTML = `
|
||
<div class="col-sm-4">
|
||
<label class="form-label small">${t('sessions.weather.temp')}</label>
|
||
<input type="number" step="0.1" class="form-control form-control-sm" id="wTemp" value="${s.temperature_c ?? ''}">
|
||
</div>
|
||
<div class="col-sm-4">
|
||
<label class="form-label small">${t('sessions.weather.wind')}</label>
|
||
<input type="number" step="0.1" class="form-control form-control-sm" id="wWind" value="${s.wind_speed_ms ?? ''}">
|
||
</div>
|
||
<div class="col-sm-4">
|
||
<label class="form-label small">${t('sessions.weather.dir')}</label>
|
||
<input type="number" class="form-control form-control-sm" id="wDir" min="0" max="359" value="${s.wind_direction_deg ?? ''}">
|
||
</div>
|
||
<div class="col-sm-4">
|
||
<label class="form-label small">${t('sessions.weather.humidity')}</label>
|
||
<input type="number" class="form-control form-control-sm" id="wHumidity" min="0" max="100" value="${s.humidity_pct ?? ''}">
|
||
</div>
|
||
<div class="col-sm-4">
|
||
<label class="form-label small">${t('sessions.weather.pressure')}</label>
|
||
<input type="number" step="0.1" class="form-control form-control-sm" id="wPressure" value="${s.pressure_hpa ?? ''}">
|
||
</div>
|
||
<div class="col-sm-12">
|
||
<label class="form-label small">${t('sessions.weather.notes')}</label>
|
||
<input type="text" class="form-control form-control-sm" id="wNotes" value="${esc(s.weather_notes || '')}">
|
||
</div>`;
|
||
|
||
// Public toggle
|
||
renderPublicToggle(s.is_public);
|
||
|
||
// Analysis section
|
||
renderAnalysisSection(s);
|
||
|
||
// PRS stages
|
||
const stagesSection = document.getElementById('stagesSection');
|
||
if (currentType === 'prs') {
|
||
stagesSection.classList.remove('d-none');
|
||
renderStages(s.stages || []);
|
||
} else {
|
||
stagesSection.classList.add('d-none');
|
||
}
|
||
}
|
||
|
||
// ── Analysis section ──────────────────────────────────────────────────────────
|
||
|
||
async function renderAnalysisSection(s) {
|
||
const body = document.getElementById('analysisBody');
|
||
if (s.analysis_detail) {
|
||
const a = s.analysis_detail;
|
||
body.innerHTML = `
|
||
<div class="d-flex align-items-center flex-wrap gap-2 mb-3">
|
||
<div>
|
||
<strong>${esc(a.name)}</strong>
|
||
${a.date ? `<span class="text-muted ms-2 small">${esc(a.date)}</span>` : ''}
|
||
</div>
|
||
<a href="/chrono.html?id=${a.id}" target="_blank" class="btn btn-sm btn-outline-secondary ms-auto">
|
||
<i class="bi bi-box-arrow-up-right me-1"></i>${t('sessions.analysis.view')}
|
||
</a>
|
||
<button class="btn btn-sm btn-outline-danger" onclick="unlinkAnalysis()">
|
||
<i class="bi bi-x-lg me-1"></i>${t('sessions.analysis.unlink')}
|
||
</button>
|
||
</div>
|
||
<div class="d-flex align-items-center gap-2 mb-3 flex-wrap">
|
||
<span class="text-muted small">Units:</span>
|
||
<div id="unitSelectorWrap" class="d-flex gap-2">${renderUnitSelector()}</div>
|
||
</div>
|
||
<div id="analysisGroups">
|
||
<div class="text-center py-3"><div class="spinner-border spinner-border-sm text-primary"></div></div>
|
||
</div>`;
|
||
loadAnalysisGroups(s.analysis);
|
||
} else {
|
||
// Show analysis picker
|
||
let analysisOpts = '<option value="">— Select —</option>';
|
||
try {
|
||
const items = await apiGetAll('/tools/chronograph/');
|
||
analysisOpts += items.map(a => `<option value="${a.id}">${esc(a.name)}${a.date ? ' — ' + a.date : ''}</option>`).join('');
|
||
} catch(e) {}
|
||
|
||
body.innerHTML = `
|
||
<p class="text-muted small mb-2">${t('sessions.analysis.none')}</p>
|
||
<div class="d-flex gap-2 align-items-end">
|
||
<select class="form-select form-select-sm" id="analysisPicker" style="max-width:300px">${analysisOpts}</select>
|
||
<button class="btn btn-sm btn-outline-primary" onclick="linkAnalysis()">
|
||
<i class="bi bi-link-45deg me-1"></i>${t('sessions.analysis.link')}
|
||
</button>
|
||
</div>`;
|
||
}
|
||
}
|
||
|
||
async function loadAnalysisGroups(analysisId) {
|
||
const container = document.getElementById('analysisGroups');
|
||
if (!container) return;
|
||
try {
|
||
const detail = await apiGet(`/tools/chronograph/${analysisId}/`);
|
||
const groups = detail.shot_groups || [];
|
||
if (!groups.length) {
|
||
container.innerHTML = '<p class="text-muted small">No shot groups in this analysis.</p>';
|
||
return;
|
||
}
|
||
const groupPhotos = await Promise.all(groups.map(g =>
|
||
apiGet(`/photos/group-photos/?shot_group=${g.id}`)
|
||
.then(d => asList(d))
|
||
.catch(() => [])
|
||
));
|
||
_analysisCache = { analysisId, groups, groupPhotos };
|
||
renderAnalysisGroupsFromCache();
|
||
} catch(e) {
|
||
const c = document.getElementById('analysisGroups');
|
||
if (c) c.innerHTML = '<span class="text-danger small">Failed to load analysis data.</span>';
|
||
}
|
||
}
|
||
|
||
function renderAnalysisGroupsFromCache() {
|
||
const container = document.getElementById('analysisGroups');
|
||
if (!container || !_analysisCache) return;
|
||
const { groups, groupPhotos } = _analysisCache;
|
||
|
||
_gpModalData = {};
|
||
|
||
container.innerHTML = groups.map((g, i) => {
|
||
const st = g.stats || {};
|
||
const gp = (groupPhotos[i] || [])[0];
|
||
const ammo = g.ammo_detail
|
||
? `${g.ammo_detail.brand} ${g.ammo_detail.name}`
|
||
: g.ammo_batch_detail
|
||
? `${g.ammo_batch_detail.recipe_name} ${g.ammo_batch_detail.powder_charge_gr}gr`
|
||
: null;
|
||
|
||
if (gp) _gpModalData[gp.id] = { gp, group: g };
|
||
|
||
const distM = g.distance_m ? parseFloat(g.distance_m) : null;
|
||
|
||
const statsHtml = st.count ? `
|
||
<div class="d-flex flex-wrap gap-3 small mt-1">
|
||
<span><span class="text-muted">Shots:</span> <strong>${st.count}</strong></span>
|
||
<span><span class="text-muted">Avg:</span> <strong>${fVel(st.avg_fps, st.avg_mps)}</strong></span>
|
||
<span><span class="text-muted">SD:</span> <strong>${fVel(st.sd_fps, (st.sd_fps * 0.3048).toFixed(1))}</strong></span>
|
||
<span><span class="text-muted">ES (vel):</span> <strong>${fVel(st.es_fps, (st.es_fps * 0.3048).toFixed(1))}</strong></span>
|
||
</div>` : '<p class="text-muted small mb-0 mt-1">No shots recorded.</p>';
|
||
|
||
const gpEsLabel = gp?.analysis?.group_size_mm != null
|
||
? `<span class="small text-muted">${fDist(gp.analysis.group_size_mm, gp.analysis.group_size_moa, distM)}</span>`
|
||
: '';
|
||
|
||
const photoHtml = gp ? `
|
||
<div class="ms-auto d-flex flex-column align-items-end gap-1" style="min-width:100px">
|
||
<img src="/api/photos/${gp.photo.id}/data/"
|
||
style="width:100px;height:75px;object-fit:cover;border-radius:5px;cursor:pointer"
|
||
onclick="openGroupPhotoModal(${gp.id})" title="Click to enlarge">
|
||
${gpEsLabel}
|
||
<div class="d-flex gap-1 flex-wrap justify-content-end">
|
||
<a href="/group-size.html?gp=${gp.id}" class="btn btn-xs btn-outline-primary py-0 px-2 small">
|
||
<i class="bi bi-crosshair2 me-1"></i>Measure
|
||
</a>
|
||
<button class="btn btn-xs btn-outline-secondary py-0 px-2 small"
|
||
onclick="openSessionPhotoModal(${g.id}, ${gp.id})">
|
||
<i class="bi bi-arrow-repeat"></i>
|
||
</button>
|
||
</div>
|
||
</div>` : `
|
||
<div class="ms-auto">
|
||
<button class="btn btn-xs btn-outline-secondary py-0 px-2 small"
|
||
onclick="openSessionPhotoModal(${g.id})">
|
||
<i class="bi bi-plus-lg me-1"></i>Add photo
|
||
</button>
|
||
</div>`;
|
||
|
||
return `
|
||
<div class="border rounded p-3 mb-2 bg-white">
|
||
<div class="d-flex align-items-start gap-2">
|
||
<div class="flex-grow-1">
|
||
<div class="fw-semibold small">
|
||
${esc(g.label)}
|
||
${g.distance_m ? `<span class="text-muted fw-normal ms-1">@ ${g.distance_m} m</span>` : ''}
|
||
${ammo ? `<span class="badge bg-light text-dark border ms-2 fw-normal">${esc(ammo)}</span>` : ''}
|
||
</div>
|
||
${statsHtml}
|
||
</div>
|
||
${photoHtml}
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderGroupPhotoModalStats(gpId) {
|
||
const data = _gpModalData[gpId];
|
||
if (!data) return;
|
||
const { gp, group } = data;
|
||
const st = group.stats || {};
|
||
const an = gp.analysis;
|
||
const distM = group.distance_m ? parseFloat(group.distance_m) : null;
|
||
|
||
const statParts = [];
|
||
if (st.count) {
|
||
statParts.push(`<span><span class="text-muted">Shots:</span> <strong>${st.count}</strong></span>`);
|
||
statParts.push(`<span><span class="text-muted">Avg:</span> <strong>${fVel(st.avg_fps, st.avg_mps)}</strong></span>`);
|
||
statParts.push(`<span><span class="text-muted">SD:</span> <strong>${fVel(st.sd_fps, (st.sd_fps * 0.3048).toFixed(1))}</strong></span>`);
|
||
statParts.push(`<span><span class="text-muted">ES (vel):</span> <strong>${fVel(st.es_fps, (st.es_fps * 0.3048).toFixed(1))}</strong></span>`);
|
||
}
|
||
if (an) {
|
||
if (an.group_size_mm != null)
|
||
statParts.push(`<span><span class="text-muted">Group ES:</span> <strong>${fDist(an.group_size_mm, an.group_size_moa, distM)}</strong></span>`);
|
||
if (an.mean_radius_mm != null)
|
||
statParts.push(`<span><span class="text-muted">Mean radius:</span> <strong>${fDist(an.mean_radius_mm, an.mean_radius_moa, distM)}</strong></span>`);
|
||
const wx = an.windage_offset_mm != null ? parseFloat(an.windage_offset_mm) : null;
|
||
const wy = an.elevation_offset_mm != null ? parseFloat(an.elevation_offset_mm) : null;
|
||
if (wx != null && wy != null && (Math.abs(wx) > 0.5 || Math.abs(wy) > 0.5)) {
|
||
const wDir = wx > 0 ? 'R' : 'L';
|
||
const eDir = wy > 0 ? 'H' : 'Low';
|
||
statParts.push(`<span><span class="text-muted">Offset:</span> <strong>${fDist(Math.abs(wx), an.windage_offset_moa, distM)} ${wDir} / ${fDist(Math.abs(wy), an.elevation_offset_moa, distM)} ${eDir}</strong></span>`);
|
||
}
|
||
}
|
||
document.getElementById('gpModalStats').innerHTML =
|
||
statParts.length ? statParts.join('') : '<span class="text-muted">No data computed yet.</span>';
|
||
}
|
||
|
||
function openGroupPhotoModal(gpId) {
|
||
const data = _gpModalData[gpId];
|
||
if (!data) return;
|
||
const { gp, group } = data;
|
||
|
||
_openModalGpId = gpId;
|
||
|
||
document.getElementById('gpModalTitle').textContent =
|
||
group.label + (group.distance_m ? ` — ${group.distance_m} m` : '');
|
||
|
||
renderGroupPhotoModalStats(gpId);
|
||
|
||
document.getElementById('gpModalMeasureBtn').href = `/group-size.html?gp=${gp.id}`;
|
||
document.getElementById('gpModalOpenBtn').href = `/api/photos/${gp.photo.id}/data/`;
|
||
document.getElementById('gpModalImg').src = `/api/photos/${gp.photo.id}/data/`;
|
||
|
||
const modalEl = document.getElementById('groupPhotoModal');
|
||
applyDistUnitButtons(modalEl);
|
||
bootstrap.Modal.getOrCreateInstance(modalEl).show();
|
||
}
|
||
|
||
document.getElementById('groupPhotoModal')?.addEventListener('hidden.bs.modal', () => {
|
||
_openModalGpId = null;
|
||
});
|
||
|
||
// ── Session photo upload ───────────────────────────────────────────────────────
|
||
|
||
function openSessionPhotoModal(groupId, existingGpId = null) {
|
||
_sessionPhotoGroupId = groupId;
|
||
_sessionPhotoGpId = existingGpId || null;
|
||
document.getElementById('sessionPhotoFileInput').value = '';
|
||
document.getElementById('sessionPhotoCaption').value = '';
|
||
document.getElementById('sessionPhotoAlert').classList.add('d-none');
|
||
const title = document.querySelector('#sessionPhotoModal .modal-title');
|
||
if (title) title.textContent = existingGpId ? 'Replace photo' : 'Add photo';
|
||
if (!_sessionPhotoModal)
|
||
_sessionPhotoModal = new bootstrap.Modal('#sessionPhotoModal');
|
||
_sessionPhotoModal.show();
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
document.getElementById('sessionPhotoSubmitBtn').addEventListener('click', async () => {
|
||
const alertEl = document.getElementById('sessionPhotoAlert');
|
||
const spinner = document.getElementById('sessionPhotoSpinner');
|
||
const submitBtn = document.getElementById('sessionPhotoSubmitBtn');
|
||
alertEl.classList.add('d-none');
|
||
|
||
const file = document.getElementById('sessionPhotoFileInput').files[0];
|
||
if (!file) {
|
||
alertEl.textContent = 'Please select an image file.';
|
||
alertEl.classList.remove('d-none');
|
||
return;
|
||
}
|
||
|
||
submitBtn.disabled = true;
|
||
spinner.classList.remove('d-none');
|
||
try {
|
||
if (_sessionPhotoGpId)
|
||
await apiDelete(`/photos/group-photos/${_sessionPhotoGpId}/`);
|
||
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
const photo = await apiFetch('/photos/upload/', { method: 'POST', body: formData })
|
||
.then(async r => {
|
||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw Object.assign(new Error('Upload failed'), { data: e }); }
|
||
return r.json();
|
||
});
|
||
|
||
const caption = document.getElementById('sessionPhotoCaption').value.trim();
|
||
await apiPost('/photos/group-photos/', {
|
||
photo_id: photo.id,
|
||
shot_group: _sessionPhotoGroupId,
|
||
...(caption ? { caption } : {}),
|
||
});
|
||
|
||
_sessionPhotoModal.hide();
|
||
showToast(_sessionPhotoGpId ? 'Photo replaced.' : 'Photo added.');
|
||
// Reload the analysis groups section
|
||
const analysisId = currentSession?.analysis;
|
||
if (analysisId) loadAnalysisGroups(analysisId);
|
||
} catch(e) {
|
||
alertEl.textContent = (e.data && (e.data.detail || JSON.stringify(e.data))) || 'Upload failed.';
|
||
alertEl.classList.remove('d-none');
|
||
} finally {
|
||
submitBtn.disabled = false;
|
||
spinner.classList.add('d-none');
|
||
}
|
||
});
|
||
});
|
||
|
||
async function linkAnalysis() {
|
||
const val = document.getElementById('analysisPicker')?.value;
|
||
if (!val) return;
|
||
try {
|
||
await apiPatch(`${TYPE_PATHS[currentType]}${currentSession.id}/`, { analysis: parseInt(val) });
|
||
currentSession = await apiGet(`${TYPE_PATHS[currentType]}${currentSession.id}/`);
|
||
renderAnalysisSection(currentSession);
|
||
showToast('Analysis linked.');
|
||
} catch(e) {
|
||
showToast('Failed to link analysis.', 'danger');
|
||
}
|
||
}
|
||
|
||
async function unlinkAnalysis() {
|
||
try {
|
||
await apiPatch(`${TYPE_PATHS[currentType]}${currentSession.id}/`, { analysis: null });
|
||
currentSession = await apiGet(`${TYPE_PATHS[currentType]}${currentSession.id}/`);
|
||
renderAnalysisSection(currentSession);
|
||
showToast('Analysis unlinked.');
|
||
} catch(e) {
|
||
showToast('Failed to unlink analysis.', 'danger');
|
||
}
|
||
}
|
||
|
||
// ── Stages (PRS) ──────────────────────────────────────────────────────────────
|
||
|
||
function ammoLabel(s) {
|
||
if (s.ammo_detail) {
|
||
const cal = s.ammo_detail.caliber_detail?.name || '';
|
||
return `${s.ammo_detail.brand} ${s.ammo_detail.name}${cal ? ' (' + cal + ')' : ''}`;
|
||
}
|
||
if (s.reloaded_batch_detail) return `${s.reloaded_batch_detail.recipe_name} — ${s.reloaded_batch_detail.powder_charge_gr}gr ${s.reloaded_batch_detail.powder}`;
|
||
return null;
|
||
}
|
||
|
||
function metaRow(pairs) {
|
||
return pairs.filter(([,v]) => v != null && v !== '').map(([k, v]) =>
|
||
`<div class="col-sm-4"><span class="text-muted">${k}</span><br><strong>${esc(String(v))}</strong></div>`
|
||
).join('');
|
||
}
|
||
|
||
function renderStages(stages) {
|
||
const list = document.getElementById('stagesList');
|
||
if (!stages.length) {
|
||
list.innerHTML = `<p class="text-muted small">${t('sessions.stages.none')}</p>`;
|
||
return;
|
||
}
|
||
list.innerHTML = stages.map(st => stageCard(st)).join('');
|
||
}
|
||
|
||
function stageCard(st) {
|
||
const hasResults = st.hits != null || st.score != null || st.time_taken_s != null;
|
||
const posLabel = { PRONE:'Prone', STANDING:'Standing', SITTING:'Sitting',
|
||
KNEELING:'Kneeling', BARRICADE:'Barricade', UNSUPPORTED:'Unsupported', OTHER:'Other' }[st.position] || st.position;
|
||
|
||
return `
|
||
<div class="card mb-2 stage-card ${hasResults ? 'has-results' : ''}" id="stage-${st.id}">
|
||
<div class="card-header d-flex align-items-center justify-content-between py-2">
|
||
<span class="fw-semibold">Stage ${st.order} — ${esc(posLabel)} @ ${st.distance_m}m</span>
|
||
<div class="d-flex gap-1">
|
||
${hasResults
|
||
? `<span class="badge bg-success">${t('sessions.stage.badge.done')}</span>`
|
||
: `<span class="badge bg-secondary">${t('sessions.stage.badge.pending')}</span>`}
|
||
<button class="btn btn-xs btn-outline-danger py-0 px-1" onclick="deleteStage(${st.id})" title="${t('btn.delete')}">
|
||
<i class="bi bi-trash"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="card-body py-2">
|
||
<div class="row g-3">
|
||
|
||
<!-- Prep -->
|
||
<div class="col-md-4">
|
||
<div class="phase-label text-secondary mb-1">${t('sessions.stage.prep')}</div>
|
||
<table class="table table-sm mb-0" style="font-size:.85rem">
|
||
<tr><td class="text-muted">${t('sessions.stage.target')}</td><td>${st.target_width_cm ? st.target_width_cm + ' × ' + st.target_height_cm + ' cm' : '—'}</td></tr>
|
||
<tr><td class="text-muted">${t('sessions.stage.maxtime')}</td><td>${st.max_time_s ? st.max_time_s + 's' : '—'}</td></tr>
|
||
<tr><td class="text-muted">${t('sessions.stage.shots')}</td><td>${st.shots_count}</td></tr>
|
||
${st.notes_prep ? `<tr><td class="text-muted">${t('sessions.stage.notes')}</td><td>${esc(st.notes_prep)}</td></tr>` : ''}
|
||
</table>
|
||
</div>
|
||
|
||
<!-- Execution -->
|
||
<div class="col-md-4">
|
||
<div class="phase-label text-primary mb-1">${t('sessions.stage.corrections')}</div>
|
||
<div class="d-flex gap-2 align-items-end mb-2">
|
||
<div>
|
||
<label class="form-label small mb-0">${t('sessions.stage.computed.elev')}</label>
|
||
<input class="form-control form-control-sm corr-field" readonly
|
||
value="${st.computed_elevation != null ? st.computed_elevation + ' ' + (st.correction_unit||'') : '—'}" style="background:#f8f9fa">
|
||
</div>
|
||
<div>
|
||
<label class="form-label small mb-0">${t('sessions.stage.computed.wind')}</label>
|
||
<input class="form-control form-control-sm corr-field" readonly
|
||
value="${st.computed_windage != null ? st.computed_windage + ' ' + (st.correction_unit||'') : '—'}" style="background:#f8f9fa">
|
||
</div>
|
||
</div>
|
||
<button class="btn btn-xs btn-outline-primary py-0 px-2 mb-2" onclick="computeCorrections(${st.id})">
|
||
<i class="bi bi-calculator me-1"></i>Compute
|
||
</button>
|
||
<div class="d-flex gap-2">
|
||
<div>
|
||
<label class="form-label small mb-0">${t('sessions.stage.actual.elev')}</label>
|
||
<input type="number" step="0.25" class="form-control form-control-sm corr-field"
|
||
id="aElev-${st.id}" value="${st.actual_elevation ?? ''}">
|
||
</div>
|
||
<div>
|
||
<label class="form-label small mb-0">${t('sessions.stage.actual.wind')}</label>
|
||
<input type="number" step="0.25" class="form-control form-control-sm corr-field"
|
||
id="aWind-${st.id}" value="${st.actual_windage ?? ''}">
|
||
</div>
|
||
<div class="align-self-end">
|
||
<button class="btn btn-xs btn-outline-secondary py-0 px-2" onclick="saveCorrections(${st.id})">${t('sessions.stage.save.corrections')}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Results -->
|
||
<div class="col-md-4">
|
||
<div class="phase-label text-success mb-1">${t('sessions.stage.results')}</div>
|
||
<div class="d-flex gap-2 align-items-end">
|
||
<div>
|
||
<label class="form-label small mb-0">${t('sessions.stage.hits')} / ${st.shots_count}</label>
|
||
<input type="number" min="0" max="${st.shots_count}" class="form-control form-control-sm corr-field"
|
||
id="rHits-${st.id}" value="${st.hits ?? ''}">
|
||
</div>
|
||
<div>
|
||
<label class="form-label small mb-0">${t('sessions.stage.score')}</label>
|
||
<input type="number" min="0" class="form-control form-control-sm corr-field"
|
||
id="rScore-${st.id}" value="${st.score ?? ''}">
|
||
</div>
|
||
<div>
|
||
<label class="form-label small mb-0">${t('sessions.stage.time')}</label>
|
||
<input type="number" step="0.1" class="form-control form-control-sm corr-field"
|
||
id="rTime-${st.id}" value="${st.time_taken_s ?? ''}">
|
||
</div>
|
||
</div>
|
||
<button class="btn btn-xs btn-outline-success py-0 px-2 mt-2" onclick="saveResults(${st.id})">
|
||
<i class="bi bi-floppy me-1"></i>${t('sessions.stage.save.results')}
|
||
</button>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
async function computeCorrections(stageId) {
|
||
try {
|
||
const result = await apiPost(
|
||
`/sessions/prs/${currentSession.id}/stages/${stageId}/compute-corrections/`, {}
|
||
);
|
||
showToast(result.message || 'Corrections computed.');
|
||
currentSession = await apiGet(`${TYPE_PATHS['prs']}${currentSession.id}/`);
|
||
renderStages(currentSession.stages || []);
|
||
} catch(e) {
|
||
showToast('Failed to compute corrections.', 'danger');
|
||
}
|
||
}
|
||
|
||
async function saveCorrections(stageId) {
|
||
const elevation = document.getElementById(`aElev-${stageId}`).value || null;
|
||
const windage = document.getElementById(`aWind-${stageId}`).value || null;
|
||
try {
|
||
await apiPatch(`/sessions/prs/${currentSession.id}/stages/${stageId}/`, {
|
||
actual_elevation: elevation,
|
||
actual_windage: windage,
|
||
});
|
||
showToast('Corrections saved.');
|
||
} catch(e) {
|
||
showToast('Failed to save corrections.', 'danger');
|
||
}
|
||
}
|
||
|
||
async function saveResults(stageId) {
|
||
const hits = document.getElementById(`rHits-${stageId}`).value || null;
|
||
const score = document.getElementById(`rScore-${stageId}`).value || null;
|
||
const time = document.getElementById(`rTime-${stageId}`).value || null;
|
||
try {
|
||
await apiPatch(`/sessions/prs/${currentSession.id}/stages/${stageId}/`, {
|
||
hits: hits ? parseInt(hits) : null,
|
||
score: score ? parseInt(score) : null,
|
||
time_taken_s: time || null,
|
||
});
|
||
showToast('Results saved.');
|
||
currentSession = await apiGet(`${TYPE_PATHS['prs']}${currentSession.id}/`);
|
||
renderStages(currentSession.stages || []);
|
||
} catch(e) {
|
||
showToast('Failed to save results.', 'danger');
|
||
}
|
||
}
|
||
|
||
async function deleteStage(stageId) {
|
||
if (!confirm('Delete this stage?')) return;
|
||
try {
|
||
await apiDelete(`/sessions/prs/${currentSession.id}/stages/${stageId}/`);
|
||
currentSession = await apiGet(`${TYPE_PATHS['prs']}${currentSession.id}/`);
|
||
renderStages(currentSession.stages || []);
|
||
showToast('Stage deleted.');
|
||
} catch(e) {
|
||
showToast('Failed to delete stage.', 'danger');
|
||
}
|
||
}
|
||
|
||
// ── Weather save ──────────────────────────────────────────────────────────────
|
||
|
||
document.getElementById('saveWeatherBtn').addEventListener('click', async () => {
|
||
if (!currentSession) return;
|
||
const payload = {
|
||
temperature_c: document.getElementById('wTemp').value || null,
|
||
wind_speed_ms: document.getElementById('wWind').value || null,
|
||
wind_direction_deg: document.getElementById('wDir').value || null,
|
||
humidity_pct: document.getElementById('wHumidity').value || null,
|
||
pressure_hpa: document.getElementById('wPressure').value || null,
|
||
weather_notes: document.getElementById('wNotes').value,
|
||
};
|
||
try {
|
||
await apiPatch(`${TYPE_PATHS[currentType]}${currentSession.id}/`, payload);
|
||
showToast(t('sessions.weather.save') + ' ✓');
|
||
} catch(e) {
|
||
showToast('Failed to save weather.', 'danger');
|
||
}
|
||
});
|
||
|
||
// ── Public toggle (renderPublicToggle → utils.js) ────────────────────────────
|
||
|
||
document.getElementById('togglePublicBtn').addEventListener('click', async () => {
|
||
if (!currentSession) return;
|
||
const newVal = !currentSession.is_public;
|
||
try {
|
||
await apiPatch(`${TYPE_PATHS[currentType]}${currentSession.id}/`, { is_public: newVal });
|
||
currentSession.is_public = newVal;
|
||
renderPublicToggle(newVal);
|
||
showToast(newVal ? 'Session is now public.' : 'Session is now private.');
|
||
} catch(e) {
|
||
showToast('Failed to update visibility.', 'danger');
|
||
}
|
||
});
|
||
|
||
// ── Delete session ────────────────────────────────────────────────────────────
|
||
|
||
document.getElementById('deleteSessionBtn').addEventListener('click', async () => {
|
||
if (!currentSession) return;
|
||
if (!confirm(t('sessions.delete.confirm'))) return;
|
||
try {
|
||
await apiDelete(`${TYPE_PATHS[currentType]}${currentSession.id}/`);
|
||
sessions = sessions.filter(s => s.id !== currentSession.id);
|
||
currentSession = null;
|
||
renderList();
|
||
document.getElementById('noSessionMsg').classList.remove('d-none');
|
||
document.getElementById('sessionDetail').classList.add('d-none');
|
||
showToast('Session deleted.');
|
||
} catch(e) {
|
||
showToast('Failed to delete session.', 'danger');
|
||
}
|
||
});
|
||
|
||
// ── Create session ────────────────────────────────────────────────────────────
|
||
|
||
const createModal = new bootstrap.Modal('#createModal');
|
||
|
||
document.getElementById('newSessionBtn').addEventListener('click', async () => {
|
||
document.getElementById('prsFields').classList.toggle('d-none', currentType !== 'prs');
|
||
document.getElementById('freePracticeFields').classList.toggle('d-none', currentType !== 'free-practice');
|
||
document.getElementById('speedFields').classList.toggle('d-none', currentType !== 'speed-shooting');
|
||
|
||
const typeLabels = {
|
||
prs: t('sessions.tab.prs'),
|
||
'free-practice': t('sessions.tab.fp'),
|
||
'speed-shooting': t('sessions.tab.speed'),
|
||
};
|
||
document.getElementById('createModalTitle').innerHTML =
|
||
`<i class="bi bi-plus-lg me-2"></i>${typeLabels[currentType]}`;
|
||
|
||
document.getElementById('createDate').value = new Date().toISOString().slice(0, 10);
|
||
document.getElementById('createAlert').classList.add('d-none');
|
||
|
||
// Load rigs
|
||
const sel = document.getElementById('createRig');
|
||
sel.innerHTML = '<option value="">— None —</option>';
|
||
try {
|
||
const rigs = await apiGet('/rigs/');
|
||
const rigData = asList(rigs);
|
||
// Cache rig details for caliber filtering
|
||
rigData.forEach(r => { _rigCache[r.id] = r; });
|
||
sel.innerHTML = '<option value="">— None —</option>' +
|
||
rigData.map(r => `<option value="${r.id}">${esc(r.name)}</option>`).join('');
|
||
} catch(e) {}
|
||
|
||
// Reset ammo
|
||
document.querySelectorAll('input[name=ammoType]').forEach(r => { r.checked = r.value === 'none'; });
|
||
document.getElementById('createAmmo').classList.add('d-none');
|
||
document.getElementById('createBatch').classList.add('d-none');
|
||
document.getElementById('suggestAmmoWrap').classList.add('d-none');
|
||
|
||
// Reset analysis
|
||
document.querySelectorAll('input[name=analysisMode]').forEach(r => { r.checked = r.value === 'none'; });
|
||
document.getElementById('createAnalysisPicker').classList.add('d-none');
|
||
document.getElementById('createAnalysisUpload').classList.add('d-none');
|
||
document.getElementById('createAnalysisCsv').value = '';
|
||
|
||
// Load existing analyses into picker (lazy, only if not already loaded)
|
||
const picker = document.getElementById('createAnalysisPicker');
|
||
if (!picker.options.length || picker.options[0]?.value === '') {
|
||
picker.innerHTML = '<option value="">— Select analysis —</option>';
|
||
try {
|
||
const items = await apiGetAll('/tools/chronograph/');
|
||
picker.innerHTML = '<option value="">— Select analysis —</option>' +
|
||
items.map(a => `<option value="${a.id}">${esc(a.name)}${a.date ? ' — ' + a.date : ''}</option>`).join('');
|
||
} catch(e) {}
|
||
}
|
||
|
||
createModal.show();
|
||
});
|
||
|
||
// ── Rig → caliber → ammo filtering ──────────────────────────────────────────
|
||
|
||
document.getElementById('createRig').addEventListener('change', () => {
|
||
// When rig changes, if factory ammo is already selected, re-filter by new caliber
|
||
const v = document.querySelector('input[name=ammoType]:checked')?.value;
|
||
if (v === 'factory') reloadAmmoForRig();
|
||
});
|
||
|
||
async function reloadAmmoForRig() {
|
||
const rigId = document.getElementById('createRig').value;
|
||
let caliber = null;
|
||
|
||
if (rigId) {
|
||
// Check cache first; if full rig detail not cached, fetch it
|
||
let rig = _rigCache[rigId];
|
||
if (!rig || rig.primary_caliber === undefined) {
|
||
try { rig = await apiGet(`/rigs/${rigId}/`); _rigCache[rigId] = rig; } catch(e) {}
|
||
}
|
||
caliber = rig?.primary_caliber || null;
|
||
}
|
||
|
||
const ammoSel = document.getElementById('createAmmo');
|
||
ammoSel.innerHTML = '<option value="">— Select ammo —</option>';
|
||
try {
|
||
const caliberId = caliber?.id || null;
|
||
const url = caliberId
|
||
? `/gears/ammo/?status=VERIFIED&caliber=${caliberId}&page_size=100`
|
||
: `/gears/ammo/?status=VERIFIED&page_size=100`;
|
||
const data = await apiGet(url);
|
||
const items = asList(data);
|
||
ammoSel.innerHTML = '<option value="">— Select ammo —</option>' +
|
||
items.map(a => {
|
||
const calName = a.caliber_detail?.name || '';
|
||
return `<option value="${a.id}">${esc(a.brand + ' ' + a.name)}${calName ? ' (' + esc(calName) + ')' : ''}</option>`;
|
||
}).join('');
|
||
if (caliberId && !items.length) {
|
||
ammoSel.innerHTML += `<option disabled>No verified ammo found for caliber ${esc(caliber.name)}</option>`;
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
|
||
// ── Ammo type radio toggle ───────────────────────────────────────────────────
|
||
|
||
document.querySelectorAll('input[name=ammoType]').forEach(radio => {
|
||
radio.addEventListener('change', async () => {
|
||
const v = document.querySelector('input[name=ammoType]:checked').value;
|
||
const ammoSel = document.getElementById('createAmmo');
|
||
const batchSel = document.getElementById('createBatch');
|
||
const suggestWrap = document.getElementById('suggestAmmoWrap');
|
||
ammoSel.classList.add('d-none');
|
||
batchSel.classList.add('d-none');
|
||
suggestWrap.classList.add('d-none');
|
||
|
||
if (v === 'factory') {
|
||
ammoSel.classList.remove('d-none');
|
||
suggestWrap.classList.remove('d-none');
|
||
await reloadAmmoForRig();
|
||
} else if (v === 'reload') {
|
||
batchSel.classList.remove('d-none');
|
||
if (!batchSel.options.length || batchSel.options[0].value === '') {
|
||
try {
|
||
const items = await apiGetAll('/reloading/batches/');
|
||
batchSel.innerHTML = '<option value="">— Select batch —</option>' +
|
||
items.map(b => `<option value="${b.id}">${esc(b.recipe || '')} — ${b.powder_charge_gr}gr</option>`).join('');
|
||
} catch(e) {}
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
// ── Analysis mode toggle ──────────────────────────────────────────────────────
|
||
|
||
document.querySelectorAll('input[name=analysisMode]').forEach(radio => {
|
||
radio.addEventListener('change', () => {
|
||
const v = document.querySelector('input[name=analysisMode]:checked').value;
|
||
document.getElementById('createAnalysisPicker').classList.toggle('d-none', v !== 'link');
|
||
document.getElementById('createAnalysisUpload').classList.toggle('d-none', v !== 'upload');
|
||
});
|
||
});
|
||
|
||
// ── Suggest ammo ─────────────────────────────────────────────────────────────
|
||
|
||
document.getElementById('toggleSuggestAmmo').addEventListener('click', async e => {
|
||
e.preventDefault();
|
||
const form = document.getElementById('suggestAmmoForm');
|
||
form.classList.toggle('d-none');
|
||
if (!form.classList.contains('d-none')) {
|
||
const sel = document.getElementById('saCaliber');
|
||
if (sel && (!sel.options.length || sel.options[0].text === 'Loading…')) {
|
||
await loadCalibersIntoSelect(sel);
|
||
}
|
||
}
|
||
});
|
||
|
||
document.getElementById('submitSuggestAmmo').addEventListener('click', async () => {
|
||
const alertEl = document.getElementById('saAlert');
|
||
alertEl.classList.add('d-none');
|
||
const brand = document.getElementById('saBrand').value.trim();
|
||
const name = document.getElementById('saName').value.trim();
|
||
const caliberId = document.getElementById('saCaliber').value;
|
||
const weight = document.getElementById('saBulletWeight').value;
|
||
const bulletType = document.getElementById('saBulletType').value;
|
||
|
||
if (!brand || !name || !caliberId || !weight || !bulletType) {
|
||
alertEl.textContent = 'Brand, name, caliber, bullet weight and bullet type are required.';
|
||
alertEl.classList.remove('d-none');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const payload = {
|
||
brand, name,
|
||
caliber: parseInt(caliberId),
|
||
bullet_weight_gr: parseFloat(weight),
|
||
bullet_type: bulletType,
|
||
};
|
||
const created = await apiPost('/gears/ammo/', payload);
|
||
// Add it to the ammo selector and select it
|
||
const ammoSel = document.getElementById('createAmmo');
|
||
const calName = created.caliber_detail?.name || '';
|
||
const opt = new Option(`${created.brand} ${created.name}${calName ? ' (' + calName + ')' : ''} — pending`, created.id, true, true);
|
||
ammoSel.appendChild(opt);
|
||
document.getElementById('suggestAmmoForm').classList.add('d-none');
|
||
showToast('Ammo suggestion submitted!');
|
||
} catch(e) {
|
||
alertEl.textContent = (e.data && formatErrors(e.data)) || 'Submission failed.';
|
||
alertEl.classList.remove('d-none');
|
||
}
|
||
});
|
||
|
||
// ── Create session submit ─────────────────────────────────────────────────────
|
||
|
||
document.getElementById('createSubmitBtn').addEventListener('click', async () => {
|
||
const alertEl = document.getElementById('createAlert');
|
||
const spinner = document.getElementById('createSpinner');
|
||
alertEl.classList.add('d-none');
|
||
|
||
const ammoType = document.querySelector('input[name=ammoType]:checked').value;
|
||
const payload = {
|
||
name: document.getElementById('createName').value.trim(),
|
||
date: document.getElementById('createDate').value,
|
||
location: document.getElementById('createLocation').value.trim(),
|
||
};
|
||
|
||
const rigVal = document.getElementById('createRig').value;
|
||
if (rigVal) payload.rig = parseInt(rigVal);
|
||
|
||
if (ammoType === 'factory') {
|
||
const v = document.getElementById('createAmmo').value;
|
||
if (v) payload.ammo = parseInt(v);
|
||
} else if (ammoType === 'reload') {
|
||
const v = document.getElementById('createBatch').value;
|
||
if (v) payload.reloaded_batch = parseInt(v);
|
||
}
|
||
|
||
if (currentType === 'prs') {
|
||
payload.competition_name = document.getElementById('createCompetition').value.trim();
|
||
payload.category = document.getElementById('createCategory').value.trim();
|
||
} else if (currentType === 'free-practice') {
|
||
const d = document.getElementById('createDistance').value;
|
||
if (d) payload.distance_m = parseInt(d);
|
||
payload.target_description = document.getElementById('createTarget').value.trim();
|
||
} else {
|
||
payload.format = document.getElementById('createFormat').value.trim();
|
||
}
|
||
|
||
if (!payload.date) {
|
||
alertEl.textContent = 'Date is required.';
|
||
alertEl.classList.remove('d-none');
|
||
return;
|
||
}
|
||
|
||
const analysisMode = document.querySelector('input[name=analysisMode]:checked').value;
|
||
if (analysisMode === 'link') {
|
||
const v = document.getElementById('createAnalysisPicker').value;
|
||
if (v) payload.analysis = parseInt(v);
|
||
}
|
||
|
||
// Validate CSV upload fields before disabling the button
|
||
if (analysisMode === 'upload') {
|
||
const csvFile = document.getElementById('createAnalysisCsv').files[0];
|
||
if (!csvFile) {
|
||
alertEl.textContent = 'Please select a CSV file for the analysis.';
|
||
alertEl.classList.remove('d-none');
|
||
return;
|
||
}
|
||
}
|
||
|
||
spinner.classList.remove('d-none');
|
||
document.getElementById('createSubmitBtn').disabled = true;
|
||
|
||
try {
|
||
// If uploading a new analysis, do that first and attach the ID
|
||
if (analysisMode === 'upload') {
|
||
const csvFile = document.getElementById('createAnalysisCsv').files[0];
|
||
const velUnit = document.querySelector('input[name=createAnalysisVelUnit]:checked')?.value || 'fps';
|
||
const chronoType = document.getElementById('createAnalysisChronoType').value;
|
||
const analysisName = document.getElementById('createAnalysisName').value.trim() || payload.name;
|
||
const formData = new FormData();
|
||
formData.append('file', csvFile);
|
||
formData.append('velocity_unit', velUnit);
|
||
formData.append('chrono_type', chronoType);
|
||
if (analysisName) formData.append('name', analysisName);
|
||
if (payload.date) formData.append('date', payload.date);
|
||
const analysis = await apiFetch('/tools/chronograph/upload/', { method: 'POST', body: formData })
|
||
.then(async r => {
|
||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw Object.assign(new Error('CSV upload failed'), { data: e }); }
|
||
return r.json();
|
||
});
|
||
payload.analysis = analysis.id;
|
||
}
|
||
|
||
const created = await apiPost(TYPE_PATHS[currentType], payload);
|
||
sessions.unshift(created);
|
||
renderList();
|
||
createModal.hide();
|
||
await selectSession(created.id);
|
||
showToast('Session created!');
|
||
} catch(e) {
|
||
alertEl.textContent = (e.data && formatErrors(e.data)) || 'Failed to create session.';
|
||
alertEl.classList.remove('d-none');
|
||
} finally {
|
||
spinner.classList.add('d-none');
|
||
document.getElementById('createSubmitBtn').disabled = false;
|
||
}
|
||
});
|
||
|
||
// ── Add Stage (PRS) ───────────────────────────────────────────────────────────
|
||
|
||
const addStageModal = new bootstrap.Modal('#addStageModal');
|
||
|
||
document.getElementById('addStageBtn').addEventListener('click', () => {
|
||
if (!currentSession) return;
|
||
const nextOrder = (currentSession.stages || []).length + 1;
|
||
document.getElementById('stageOrder').value = nextOrder;
|
||
document.getElementById('stagePosition').value = 'PRONE';
|
||
document.getElementById('stageDistance').value = '';
|
||
document.getElementById('stageMaxTime').value = '';
|
||
document.getElementById('stageShots').value = '1';
|
||
document.getElementById('stageTargetW').value = '';
|
||
document.getElementById('stageTargetH').value = '';
|
||
document.getElementById('stageNotePrep').value = '';
|
||
document.getElementById('stageAlert').classList.add('d-none');
|
||
addStageModal.show();
|
||
});
|
||
|
||
document.getElementById('stageSubmitBtn').addEventListener('click', async () => {
|
||
const alertEl = document.getElementById('stageAlert');
|
||
alertEl.classList.add('d-none');
|
||
|
||
const dist = document.getElementById('stageDistance').value;
|
||
if (!dist) {
|
||
alertEl.textContent = 'Distance is required.';
|
||
alertEl.classList.remove('d-none');
|
||
return;
|
||
}
|
||
|
||
const payload = {
|
||
order: parseInt(document.getElementById('stageOrder').value),
|
||
position: document.getElementById('stagePosition').value,
|
||
distance_m: parseInt(dist),
|
||
shots_count: parseInt(document.getElementById('stageShots').value) || 1,
|
||
notes_prep: document.getElementById('stageNotePrep').value.trim(),
|
||
};
|
||
const maxTime = document.getElementById('stageMaxTime').value;
|
||
if (maxTime) payload.max_time_s = parseInt(maxTime);
|
||
const tw = document.getElementById('stageTargetW').value;
|
||
const th = document.getElementById('stageTargetH').value;
|
||
if (tw) payload.target_width_cm = parseFloat(tw);
|
||
if (th) payload.target_height_cm = parseFloat(th);
|
||
|
||
try {
|
||
await apiPost(`/sessions/prs/${currentSession.id}/stages/`, payload);
|
||
currentSession = await apiGet(`${TYPE_PATHS['prs']}${currentSession.id}/`);
|
||
renderStages(currentSession.stages || []);
|
||
addStageModal.hide();
|
||
showToast('Stage added.');
|
||
} catch(e) {
|
||
alertEl.textContent = (e.data && formatErrors(e.data)) || 'Failed to add stage.';
|
||
alertEl.classList.remove('d-none');
|
||
}
|
||
});
|
||
|
||
// ── Utility ───────────────────────────────────────────────────────────────────
|
||
|
||
function esc(s) {
|
||
if (s == null) return '';
|
||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||
|
||
applyTranslations();
|
||
loadSessions();
|