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