Files
ShooterHub/frontend/js/sessions.js

1085 lines
47 KiB
JavaScript
Raw Normal View History

// ── 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();