// ── 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 `
${['fps','mps'].map(u => ` `).join('')}
${['mm','moa','mrad'].map(u => ` `).join('')}
`; } 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 = '

Failed to load sessions.

'; } } 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 `
${esc(label)}
${esc(sub)}
`; }).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 = `
`; // 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 = `
${esc(a.name)} ${a.date ? `${esc(a.date)}` : ''}
${t('sessions.analysis.view')}
Units:
${renderUnitSelector()}
`; loadAnalysisGroups(s.analysis); } else { // Show analysis picker let analysisOpts = ''; try { const items = await apiGetAll('/tools/chronograph/'); analysisOpts += items.map(a => ``).join(''); } catch(e) {} body.innerHTML = `

${t('sessions.analysis.none')}

`; } } 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 = '

No shot groups in this analysis.

'; 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 = 'Failed to load analysis data.'; } } 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 ? `
Shots: ${st.count} Avg: ${fVel(st.avg_fps, st.avg_mps)} SD: ${fVel(st.sd_fps, (st.sd_fps * 0.3048).toFixed(1))} ES (vel): ${fVel(st.es_fps, (st.es_fps * 0.3048).toFixed(1))}
` : '

No shots recorded.

'; const gpEsLabel = gp?.analysis?.group_size_mm != null ? `${fDist(gp.analysis.group_size_mm, gp.analysis.group_size_moa, distM)}` : ''; const photoHtml = gp ? `
${gpEsLabel}
Measure
` : `
`; return `
${esc(g.label)} ${g.distance_m ? `@ ${g.distance_m} m` : ''} ${ammo ? `${esc(ammo)}` : ''}
${statsHtml}
${photoHtml}
`; }).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(`Shots: ${st.count}`); statParts.push(`Avg: ${fVel(st.avg_fps, st.avg_mps)}`); statParts.push(`SD: ${fVel(st.sd_fps, (st.sd_fps * 0.3048).toFixed(1))}`); statParts.push(`ES (vel): ${fVel(st.es_fps, (st.es_fps * 0.3048).toFixed(1))}`); } if (an) { if (an.group_size_mm != null) statParts.push(`Group ES: ${fDist(an.group_size_mm, an.group_size_moa, distM)}`); if (an.mean_radius_mm != null) statParts.push(`Mean radius: ${fDist(an.mean_radius_mm, an.mean_radius_moa, distM)}`); 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(`Offset: ${fDist(Math.abs(wx), an.windage_offset_moa, distM)} ${wDir} / ${fDist(Math.abs(wy), an.elevation_offset_moa, distM)} ${eDir}`); } } document.getElementById('gpModalStats').innerHTML = statParts.length ? statParts.join('') : 'No data computed yet.'; } 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]) => `
${k}
${esc(String(v))}
` ).join(''); } function renderStages(stages) { const list = document.getElementById('stagesList'); if (!stages.length) { list.innerHTML = `

${t('sessions.stages.none')}

`; 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 `
Stage ${st.order} — ${esc(posLabel)} @ ${st.distance_m}m
${hasResults ? `${t('sessions.stage.badge.done')}` : `${t('sessions.stage.badge.pending')}`}
${t('sessions.stage.prep')}
${st.notes_prep ? `` : ''}
${t('sessions.stage.target')}${st.target_width_cm ? st.target_width_cm + ' × ' + st.target_height_cm + ' cm' : '—'}
${t('sessions.stage.maxtime')}${st.max_time_s ? st.max_time_s + 's' : '—'}
${t('sessions.stage.shots')}${st.shots_count}
${t('sessions.stage.notes')}${esc(st.notes_prep)}
${t('sessions.stage.corrections')}
${t('sessions.stage.results')}
`; } 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 = `${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 = ''; 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 = '' + rigData.map(r => ``).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 = ''; try { const items = await apiGetAll('/tools/chronograph/'); picker.innerHTML = '' + items.map(a => ``).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 = ''; 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 = '' + items.map(a => { const calName = a.caliber_detail?.name || ''; return ``; }).join(''); if (caliberId && !items.length) { ammoSel.innerHTML += ``; } } 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 = '' + items.map(b => ``).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,'"'); } // ── Init ────────────────────────────────────────────────────────────────────── applyTranslations(); loadSessions();