Files
ShooterHub/frontend/js/sessions.js
2026-04-02 11:24:30 +02:00

1085 lines
47 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ── 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── Init ──────────────────────────────────────────────────────────────────────
applyTranslations();
loadSessions();