WIP: claude works hard

This commit is contained in:
Gérald Colangelo
2026-03-19 16:42:37 +01:00
parent 5b18fadb60
commit 54b8cc991e
38 changed files with 3492 additions and 317 deletions

View File

@@ -7,6 +7,7 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN pybabel compile -d translations 2>/dev/null || true
RUN chmod +x entrypoint.sh
ENV FLASK_APP=app

143
analyzer/dope_card.py Normal file
View File

@@ -0,0 +1,143 @@
"""Generate a printable PRS dope card as PDF (A4 portrait)."""
from fpdf import FPDF
# Column widths in mm
_W = {
"num": 10,
"name": 28,
"dist": 20,
"time": 18,
"pos": 26,
"dope_e": 30,
"dope_w": 30,
"hits": 24,
"notes": 0, # fills remaining width
}
_ROW_H = 8
_HEAD_H = 9
_DARK = (26, 26, 46) # #1a1a2e
_LIGHT = (240, 244, 255) # #f0f4ff
_GRID = (200, 210, 230)
def _notes_w(epw: float) -> float:
fixed = sum(v for k, v in _W.items() if k != "notes")
return max(0, epw - fixed)
def generate_dope_card(session, stages: list) -> bytes:
pdf = FPDF(orientation="P", unit="mm", format="A4")
pdf.set_auto_page_break(auto=True, margin=15)
pdf.add_page()
pdf.set_margins(10, 12, 10)
epw = pdf.w - pdf.l_margin - pdf.r_margin
nw = _notes_w(epw)
# ── Header ──────────────────────────────────────────────────────────────
pdf.set_font("Helvetica", "B", 18)
pdf.set_text_color(*_DARK)
pdf.cell(0, 10, "FICHE DE TIR — PRS", new_x="LMARGIN", new_y="NEXT", align="C")
pdf.set_font("Helvetica", "", 9)
pdf.set_text_color(80, 80, 80)
parts = [session.session_date.strftime("%d/%m/%Y")]
if session.location_name:
parts.append(session.location_name)
if session.rifle:
parts.append(session.rifle.name)
if session.rifle.caliber:
parts.append(session.rifle.caliber)
if session.ammo_brand:
parts.append(session.ammo_brand)
if session.ammo_weight_gr:
parts.append(f"{session.ammo_weight_gr} gr")
pdf.cell(0, 5, " | ".join(parts), new_x="LMARGIN", new_y="NEXT", align="C")
pdf.ln(4)
# ── Column headers ───────────────────────────────────────────────────────
pdf.set_fill_color(*_DARK)
pdf.set_text_color(255, 255, 255)
pdf.set_font("Helvetica", "B", 8)
headers = [
("", _W["num"]),
("Nom", _W["name"]),
("Dist.(m)", _W["dist"]),
("Temps(s)", _W["time"]),
("Position", _W["pos"]),
("Dope Élév.", _W["dope_e"]),
("Dope Dérive", _W["dope_w"]),
("Coups/Poss.", _W["hits"]),
("Notes", nw),
]
for label, w in headers:
pdf.cell(w, _HEAD_H, label, border=0, fill=True, align="C")
pdf.ln()
# ── Stage rows ───────────────────────────────────────────────────────────
pdf.set_text_color(30, 30, 30)
pdf.set_font("Helvetica", "", 9)
for i, st in enumerate(stages):
fill = i % 2 == 0
pdf.set_fill_color(*(_LIGHT if fill else (255, 255, 255)))
pdf.set_draw_color(*_GRID)
hits_str = ""
if st.get("hits") is not None:
hits_str = str(st["hits"])
if st.get("possible"):
hits_str += f"/{st['possible']}"
elif st.get("possible"):
hits_str = f"—/{st['possible']}"
row = [
(str(st.get("num", i + 1)), _W["num"], "C"),
(st.get("name") or "", _W["name"], "L"),
(str(st.get("distance_m") or ""), _W["dist"], "C"),
(str(st.get("time_s") or ""), _W["time"], "C"),
(_pos_label(st.get("position", "")), _W["pos"], "L"),
(st.get("dope_elevation") or "", _W["dope_e"], "C"),
(st.get("dope_windage") or "", _W["dope_w"], "C"),
(hits_str, _W["hits"], "C"),
(st.get("notes") or "", nw, "L"),
]
for val, w, align in row:
pdf.cell(w, _ROW_H, val, border="B", fill=fill, align=align)
pdf.ln()
# ── Blank rows for hand-written stages ──────────────────────────────────
spare = max(0, 10 - len(stages))
for i in range(min(spare, 5)):
fill = (len(stages) + i) % 2 == 0
pdf.set_fill_color(*(_LIGHT if fill else (255, 255, 255)))
for _, w, _ in row: # reuse last row widths
pdf.cell(w, _ROW_H, "", border="B", fill=fill)
pdf.ln()
# ── Footer ───────────────────────────────────────────────────────────────
pdf.ln(4)
pdf.set_font("Helvetica", "I", 7)
pdf.set_text_color(160, 160, 160)
pdf.cell(0, 5, "The Shooter's Network — fiche générée automatiquement",
new_x="LMARGIN", new_y="NEXT", align="C")
return bytes(pdf.output())
_POSITION_LABELS = {
"prone": "Couché",
"standing": "Debout",
"kneeling": "Agenouillé",
"sitting": "Assis",
"barricade": "Barricade",
"rooftop": "Toit",
"unknown": "Variable",
}
def _pos_label(slug: str) -> str:
return _POSITION_LABELS.get(slug, slug.replace("_", " ").title() if slug else "")

View File

@@ -4,7 +4,15 @@ import pandas as pd
OUTLIER_FACTOR = 5
def detect_groups(df: pd.DataFrame) -> list:
def detect_groups(df: pd.DataFrame, outlier_factor: float = OUTLIER_FACTOR,
manual_splits: list | None = None) -> list:
"""Split shots into groups.
Auto-detection: consecutive shots with a time gap > median_gap * outlier_factor
start a new group. manual_splits is an optional list of shot indices (0-based
positions in df) where a split should be forced, regardless of timing.
Both mechanisms are merged and deduplicated.
"""
if len(df) <= 1:
return [df]
@@ -16,23 +24,25 @@ def detect_groups(df: pd.DataFrame) -> list:
median_gap = diffs.median()
if median_gap == timedelta(0):
return [df]
# Auto-detect splits based on time gaps
auto_splits: set[int] = set()
if median_gap != timedelta(0):
threshold = outlier_factor * median_gap
for idx, gap in diffs.items():
if gap > threshold:
pos = df.index.get_loc(idx)
auto_splits.add(pos)
threshold = OUTLIER_FACTOR * median_gap
# Merge with manual splits (filter to valid range)
extra = set(manual_splits) if manual_splits else set()
all_splits = sorted(auto_splits | extra)
split_positions = []
for idx, gap in diffs.items():
if gap > threshold:
pos = df.index.get_loc(idx)
split_positions.append(pos)
if not split_positions:
if not all_splits:
return [df]
groups = []
prev = 0
for pos in split_positions:
for pos in all_splits:
group = df.iloc[prev:pos]
if len(group) > 0:
groups.append(group.reset_index(drop=True))

26
app.py
View File

@@ -1,12 +1,21 @@
import base64
import io
from flask import Flask, request, render_template
from flask import Flask, redirect, request, render_template, session as flask_session
from flask_login import current_user
from sqlalchemy import select
from config import Config
from extensions import db, jwt, login_manager, migrate, oauth
from extensions import babel, db, jwt, login_manager, migrate, oauth
SUPPORTED_LANGS = ["fr", "en", "de"]
def _select_locale():
lang = flask_session.get("lang")
if lang in SUPPORTED_LANGS:
return lang
return request.accept_languages.best_match(SUPPORTED_LANGS) or "en"
def create_app(config_class=Config):
@@ -17,6 +26,13 @@ def create_app(config_class=Config):
migrate.init_app(app, db)
login_manager.init_app(app)
jwt.init_app(app)
babel.init_app(app, locale_selector=_select_locale)
@app.context_processor
def inject_locale():
from flask_babel import get_locale
return {"current_lang": str(get_locale())}
@jwt.unauthorized_loader
def unauthorized_callback(reason):
@@ -96,6 +112,12 @@ def create_app(config_class=Config):
public_sessions=public_sessions,
equipment=equipment)
@app.route("/set-lang/<lang>")
def set_lang(lang: str):
if lang in SUPPORTED_LANGS:
flask_session["lang"] = lang
return redirect(request.referrer or "/")
@app.route("/")
def index():
from models import ShootingSession

3
babel.cfg Normal file
View File

@@ -0,0 +1,3 @@
[python: **.py]
[jinja2: **/templates/**.html]
extensions=jinja2.ext.autoescape,jinja2.ext.with_

View File

@@ -1,10 +1,12 @@
import io
import json
from pathlib import Path
from flask import (
Blueprint, abort, current_app, flash, redirect,
Blueprint, abort, current_app, flash, redirect, request,
render_template, send_from_directory, url_for,
)
from flask_babel import _
from flask_login import current_user, login_required
from extensions import db
@@ -83,7 +85,122 @@ def delete(analysis_id: int):
db.session.delete(a)
db.session.commit()
flash("Analysis deleted.", "success")
flash(_("Analysis deleted."), "success")
return redirect(back)
@analyses_bp.route("/<int:analysis_id>/rename", methods=["POST"])
@login_required
def rename(analysis_id: int):
a = db.session.get(Analysis, analysis_id)
if a is None:
abort(404)
if a.user_id != current_user.id:
abort(403)
title = request.form.get("title", "").strip()
if title:
a.title = title[:255]
db.session.commit()
flash(_("Title updated."), "success")
back = (url_for("sessions.detail", session_id=a.session_id)
if a.session_id else url_for("analyses.detail", analysis_id=a.id))
return redirect(back)
@analyses_bp.route("/<int:analysis_id>/regroup", methods=["POST"])
@login_required
def regroup(analysis_id: int):
a = db.session.get(Analysis, analysis_id)
if a is None:
abort(404)
if a.user_id != current_user.id:
abort(403)
try:
outlier_factor = float(request.form.get("outlier_factor", 5))
outlier_factor = max(1.0, min(20.0, outlier_factor))
except (TypeError, ValueError):
outlier_factor = 5.0
manual_splits_raw = request.form.get("manual_splits", "").strip()
manual_splits = None
if manual_splits_raw:
try:
parsed = json.loads(manual_splits_raw)
if isinstance(parsed, list):
manual_splits = [int(x) for x in parsed]
except (json.JSONDecodeError, ValueError, TypeError):
pass
storage_root = current_app.config["STORAGE_ROOT"]
csv_path = Path(storage_root) / a.csv_path
if not csv_path.exists():
abort(410)
from analyzer.parser import parse_csv
from analyzer.grouper import detect_groups
from analyzer.stats import compute_overall_stats, compute_group_stats
from analyzer.charts import render_group_charts, render_overview_chart
from analyzer.pdf_report import generate_pdf
from storage import _to_python
csv_bytes = csv_path.read_bytes()
df = parse_csv(io.BytesIO(csv_bytes))
groups = detect_groups(df, outlier_factor=outlier_factor, manual_splits=manual_splits)
overall = compute_overall_stats(df)
group_stats = compute_group_stats(groups)
# Preserve existing notes
old_stats = a.group_stats or []
for i, gs in enumerate(group_stats):
if i < len(old_stats) and old_stats[i].get("note"):
gs["note"] = old_stats[i]["note"]
charts = render_group_charts(groups, y_min=overall["min_speed"], y_max=overall["max_speed"])
overview_chart = render_overview_chart(group_stats)
pdf_bytes = generate_pdf(overall, group_stats, charts, overview_chart)
if a.pdf_path:
pdf_path = Path(storage_root) / a.pdf_path
try:
pdf_path.write_bytes(pdf_bytes)
except Exception:
pass
a.grouping_outlier_factor = outlier_factor
a.grouping_manual_splits = manual_splits
a.group_stats = _to_python(group_stats)
a.overall_stats = _to_python(overall)
a.shot_count = int(overall.get("count", 0))
a.group_count = len(group_stats)
db.session.commit()
flash(_("Regrouped."), "success")
back = (url_for("sessions.detail", session_id=a.session_id)
if a.session_id else url_for("analyses.detail", analysis_id=a.id))
return redirect(back)
@analyses_bp.route("/<int:analysis_id>/groups/<int:group_index>/note", methods=["POST"])
@login_required
def save_group_note(analysis_id: int, group_index: int):
a = db.session.get(Analysis, analysis_id)
if a is None:
abort(404)
if a.user_id != current_user.id:
abort(403)
note = request.form.get("note", "").strip()
group_stats = list(a.group_stats or [])
if 0 <= group_index < len(group_stats):
group_stats[group_index] = dict(group_stats[group_index])
group_stats[group_index]["note"] = note or None
a.group_stats = group_stats
db.session.commit()
flash(_("Note saved."), "success")
back = (url_for("sessions.detail", session_id=a.session_id)
if a.session_id else url_for("analyses.detail", analysis_id=a.id))
return redirect(back)

View File

@@ -14,6 +14,7 @@ from flask import (
send_from_directory,
url_for,
)
from flask_babel import _
from flask_login import current_user, login_required, login_user, logout_user
from sqlalchemy.exc import IntegrityError
@@ -96,11 +97,11 @@ def login():
)
if user is None or not user.check_password(password):
flash("Invalid email or password.", "error")
flash(_("Invalid email or password."), "error")
return render_template("auth/login.html", prefill_email=email)
if current_app.config["EMAIL_CONFIRMATION_REQUIRED"] and not user.email_confirmed:
flash("Please confirm your email address before logging in.", "error")
flash(_("Please confirm your email address before logging in."), "error")
return render_template("auth/login.html", prefill_email=email,
show_resend=True, resend_email=email)
@@ -125,20 +126,22 @@ def register():
# Validate
error = None
if not email or "@" not in email or "." not in email.split("@")[-1]:
error = "Please enter a valid email address."
error = _("Please enter a valid email address.")
elif len(password) < 8:
error = "Password must be at least 8 characters."
error = _("Password must be at least 8 characters.")
elif password != confirm:
error = "Passwords do not match."
error = _("Passwords do not match.")
else:
existing = db.session.scalar(db.select(User).filter_by(email=email))
if existing:
if existing.provider == "local":
error = "An account with this email already exists."
error = _("An account with this email already exists.")
else:
error = (
f"This email is linked to a {existing.provider.title()} account. "
f"Please log in with {existing.provider.title()}."
error = _(
"This email is linked to a %(provider)s account. "
"Please log in with %(provider2)s.",
provider=existing.provider.title(),
provider2=existing.provider.title(),
)
if error:
@@ -165,7 +168,7 @@ def register():
return render_template("auth/confirm_pending.html", email=email)
login_user(user)
flash("Account created! Welcome.", "success")
flash(_("Account created! Welcome."), "success")
return redirect(url_for("dashboard.index"))
return render_template("auth/register.html")
@@ -177,14 +180,14 @@ def confirm_email(token: str):
db.select(User).filter_by(email_confirm_token=token)
)
if user is None:
flash("Invalid or expired confirmation link.", "error")
flash(_("Invalid or expired confirmation link."), "error")
return redirect(url_for("auth.login"))
user.email_confirmed = True
user.email_confirm_token = None
db.session.commit()
login_user(user)
flash("Email confirmed! Welcome.", "success")
flash(_("Email confirmed! Welcome."), "success")
return redirect(url_for("dashboard.index"))
@@ -200,7 +203,7 @@ def resend_confirmation():
db.session.commit()
_dispatch_confirmation(user)
# Vague message to prevent email enumeration
flash("If that account exists and is unconfirmed, a new link has been sent.", "message")
flash(_("If that account exists and is unconfirmed, a new link has been sent."), "message")
return redirect(url_for("auth.login"))
@@ -220,13 +223,13 @@ def callback_google():
try:
token = oauth.google.authorize_access_token()
except Exception:
flash("Google login failed. Please try again.", "error")
flash(_("Google login failed. Please try again."), "error")
return redirect(url_for("auth.login"))
info = token.get("userinfo") or {}
email = info.get("email")
if not email:
flash("Could not retrieve your email from Google.", "error")
flash(_("Could not retrieve your email from Google."), "error")
return redirect(url_for("auth.login"))
user = _upsert_oauth_user(
@@ -237,7 +240,7 @@ def callback_google():
avatar_url=info.get("picture"),
)
if user is None:
flash("This email is already registered with a different login method.", "error")
flash(_("This email is already registered with a different login method."), "error")
return redirect(url_for("auth.login"))
login_user(user)
@@ -260,7 +263,7 @@ def callback_github():
try:
token = oauth.github.authorize_access_token()
except Exception:
flash("GitHub login failed. Please try again.", "error")
flash(_("GitHub login failed. Please try again."), "error")
return redirect(url_for("auth.login"))
resp = oauth.github.get("user", token=token)
@@ -276,7 +279,7 @@ def callback_github():
)
if not email:
flash("Could not retrieve a verified email from GitHub.", "error")
flash(_("Could not retrieve a verified email from GitHub."), "error")
return redirect(url_for("auth.login"))
user = _upsert_oauth_user(
@@ -287,7 +290,7 @@ def callback_github():
avatar_url=info.get("avatar_url"),
)
if user is None:
flash("This email is already registered with a different login method.", "error")
flash(_("This email is already registered with a different login method."), "error")
return redirect(url_for("auth.login"))
login_user(user)
@@ -307,7 +310,7 @@ def profile():
if action == "update_profile":
display_name = request.form.get("display_name", "").strip()
if not display_name:
flash("Display name cannot be empty.", "error")
flash(_("Display name cannot be empty."), "error")
else:
current_user.display_name = display_name
current_user.bio = request.form.get("bio", "").strip() or None
@@ -325,25 +328,25 @@ def profile():
db.session.rollback()
return render_template("auth/profile.html")
db.session.commit()
flash("Profile updated.", "success")
flash(_("Profile updated."), "success")
elif action == "change_password":
if current_user.provider != "local":
flash("Password change is only available for local accounts.", "error")
flash(_("Password change is only available for local accounts."), "error")
else:
current_pw = request.form.get("current_password", "")
new_pw = request.form.get("new_password", "")
confirm_pw = request.form.get("confirm_password", "")
if not current_user.check_password(current_pw):
flash("Current password is incorrect.", "error")
flash(_("Current password is incorrect."), "error")
elif len(new_pw) < 8:
flash("New password must be at least 8 characters.", "error")
flash(_("New password must be at least 8 characters."), "error")
elif new_pw != confirm_pw:
flash("Passwords do not match.", "error")
flash(_("Passwords do not match."), "error")
else:
current_user.set_password(new_pw)
db.session.commit()
flash("Password changed.", "success")
flash(_("Password changed."), "success")
return redirect(url_for("auth.profile"))

View File

@@ -5,6 +5,7 @@ from flask import (
request, send_from_directory, url_for,
)
from flask import Blueprint
from flask_babel import _
from flask_login import current_user, login_required
from sqlalchemy import select
@@ -41,9 +42,9 @@ def _apply_form(item: EquipmentItem) -> str | None:
name = request.form.get("name", "").strip()
category = request.form.get("category", "").strip()
if not name:
return "Name is required."
return _("Name is required.")
if category not in CATEGORY_KEYS:
return "Invalid category."
return _("Invalid category.")
item.name = name
item.category = category
item.brand = request.form.get("brand", "").strip() or None
@@ -93,7 +94,7 @@ def new():
db.session.flush()
_handle_photo(item, is_new=True)
db.session.commit()
flash(f"'{item.name}' added.", "success")
flash(_("'%(name)s' added.", name=item.name), "success")
return redirect(url_for("equipment.detail", item_id=item.id))
return render_template("equipment/form.html", item=None, categories=CATEGORIES)
@@ -117,7 +118,7 @@ def edit(item_id: int):
categories=CATEGORIES, prefill=request.form)
_handle_photo(item, is_new=False)
db.session.commit()
flash(f"'{item.name}' updated.", "success")
flash(_("'%(name)s' updated.", name=item.name), "success")
return redirect(url_for("equipment.detail", item_id=item.id))
return render_template("equipment/form.html", item=item, categories=CATEGORIES)
@@ -131,7 +132,7 @@ def delete(item_id: int):
_remove_photo_file(item.photo_path)
db.session.delete(item)
db.session.commit()
flash(f"'{name}' deleted.", "success")
flash(_("'%(name)s' deleted.", name=name), "success")
return redirect(url_for("equipment.index"))
@@ -140,7 +141,7 @@ def delete(item_id: int):
def rotate_photo_view(item_id: int):
item = _own_item(item_id)
if not item.photo_path:
flash("No photo to rotate.", "error")
flash(_("No photo to rotate."), "error")
return redirect(url_for("equipment.detail", item_id=item_id))
try:
degrees = int(request.form.get("degrees", 0))

View File

@@ -6,6 +6,7 @@ from flask import (
Blueprint, abort, current_app, flash, jsonify, redirect,
render_template, request, send_from_directory, url_for,
)
from flask_babel import _
from flask_login import current_user, login_required
from sqlalchemy import select
@@ -15,6 +16,42 @@ from storage import rotate_photo, save_session_photo
sessions_bp = Blueprint("sessions", __name__, url_prefix="/sessions")
# (slug, display name, short description)
SESSION_TYPES = [
("long_range", "Long Range Practice", "Long range precision shooting (100m+)"),
("prs", "PRS", "Precision Rifle Series — training & competition"),
("pistol_25m", "25m Pistol", "25m precision pistol shooting"),
]
LONG_RANGE_POSITIONS = [
("", "— select —"),
("prone", "Prone"),
("bench", "Bench rest"),
("standing", "Standing"),
("kneeling", "Kneeling"),
]
PISTOL_25M_POSITIONS = [
("", "— select —"),
("debout", "Standing"),
("debout_appui", "Standing with support"),
("assis", "Sitting"),
("assis_appui", "Sitting with support"),
]
PRS_STAGE_POSITIONS = [
("prone", "Prone"),
("standing", "Standing"),
("kneeling", "Kneeling"),
("sitting", "Sitting"),
("barricade", "Barricade"),
("rooftop", "Rooftop"),
("unknown", "Variable"),
]
# Kept as reference but not used in UI
_FIXED_DISTANCES = {"pistol_25m": 25}
WEATHER_CONDITIONS = [
("", "— select —"),
("sunny", "Sunny"),
@@ -80,6 +117,8 @@ def _apply_form(s: ShootingSession) -> str | None:
s.ammo_weight_gr = _float_or_none(request.form.get("ammo_weight_gr"))
s.ammo_lot = request.form.get("ammo_lot", "").strip() or None
s.notes = request.form.get("notes", "").strip() or None
s.session_type = request.form.get("session_type") or None
s.shooting_position = request.form.get("shooting_position") or None
return None
@@ -134,14 +173,28 @@ def new():
return render_template("sessions/form.html", session=None,
rifles=_user_rifles(), scopes=_user_scopes(),
weather_conditions=WEATHER_CONDITIONS,
session_types=SESSION_TYPES,
long_range_positions=LONG_RANGE_POSITIONS,
pistol_25m_positions=PISTOL_25M_POSITIONS,
prefill=request.form)
db.session.commit()
flash("Session created.", "success")
flash(_("Session saved."), "success")
return redirect(url_for("sessions.detail", session_id=s.id))
return render_template("sessions/form.html", session=None,
rifles=_user_rifles(), scopes=_user_scopes(),
weather_conditions=WEATHER_CONDITIONS,
today=date.today().isoformat())
selected_type = request.args.get("type")
if selected_type:
prefill_distance = _FIXED_DISTANCES.get(selected_type)
return render_template("sessions/form.html", session=None,
rifles=_user_rifles(), scopes=_user_scopes(),
weather_conditions=WEATHER_CONDITIONS,
session_types=SESSION_TYPES,
long_range_positions=LONG_RANGE_POSITIONS,
pistol_25m_positions=PISTOL_25M_POSITIONS,
selected_type=selected_type,
prefill_distance=prefill_distance,
today=date.today().isoformat())
# Step 1: show type picker
return render_template("sessions/type_picker.html", session_types=SESSION_TYPES)
@sessions_bp.route("/<int:session_id>")
@@ -157,8 +210,43 @@ def detail(session_id: int):
.where(Analysis.session_id == session_id)
.order_by(Analysis.created_at)
).all()
from analyzer.parser import parse_csv
from analyzer.grouper import detect_groups, OUTLIER_FACTOR
from analyzer.stats import compute_overall_stats, compute_group_stats
from analyzer.charts import render_group_charts, render_overview_chart
storage_root = current_app.config["STORAGE_ROOT"]
analyses_display = []
for a in analyses:
csv_path = Path(storage_root) / a.csv_path
if csv_path.exists():
try:
df = parse_csv(io.BytesIO(csv_path.read_bytes()))
factor = a.grouping_outlier_factor if a.grouping_outlier_factor is not None else OUTLIER_FACTOR
splits = a.grouping_manual_splits or None
groups = detect_groups(df, outlier_factor=factor, manual_splits=splits)
overall = compute_overall_stats(df)
group_stats = compute_group_stats(groups)
# Merge stored notes into freshly computed stats
stored = a.group_stats or []
for i, gs in enumerate(group_stats):
if i < len(stored) and stored[i].get("note"):
gs["note"] = stored[i]["note"]
charts = render_group_charts(groups,
y_min=overall["min_speed"],
y_max=overall["max_speed"])
overview_chart = render_overview_chart(group_stats)
groups_display = list(zip(group_stats, charts))
analyses_display.append((a, groups_display, overview_chart))
except Exception:
analyses_display.append((a, None, None))
else:
analyses_display.append((a, None, None))
return render_template("sessions/detail.html", session=s,
analyses=analyses, is_owner=is_owner)
analyses=analyses, analyses_display=analyses_display,
is_owner=is_owner)
@sessions_bp.route("/<int:session_id>/edit", methods=["GET", "POST"])
@@ -172,15 +260,21 @@ def edit(session_id: int):
return render_template("sessions/form.html", session=s,
rifles=_user_rifles(), scopes=_user_scopes(),
weather_conditions=WEATHER_CONDITIONS,
session_types=SESSION_TYPES,
long_range_positions=LONG_RANGE_POSITIONS,
pistol_25m_positions=PISTOL_25M_POSITIONS,
prefill=request.form)
for analysis in s.analyses:
analysis.is_public = s.is_public
db.session.commit()
flash("Session updated.", "success")
flash(_("Session saved."), "success")
return redirect(url_for("sessions.detail", session_id=s.id))
return render_template("sessions/form.html", session=s,
rifles=_user_rifles(), scopes=_user_scopes(),
weather_conditions=WEATHER_CONDITIONS)
weather_conditions=WEATHER_CONDITIONS,
session_types=SESSION_TYPES,
long_range_positions=LONG_RANGE_POSITIONS,
pistol_25m_positions=PISTOL_25M_POSITIONS)
@sessions_bp.route("/<int:session_id>/delete", methods=["POST"])
@@ -191,10 +285,55 @@ def delete(session_id: int):
_remove_file(photo.photo_path)
db.session.delete(s)
db.session.commit()
flash("Session deleted.", "success")
flash(_("Session deleted."), "success")
return redirect(url_for("sessions.index"))
# ---------------------------------------------------------------------------
# PRS stages
# ---------------------------------------------------------------------------
@sessions_bp.route("/<int:session_id>/stages", methods=["POST"])
@login_required
def save_stages(session_id: int):
import json
s = _own_session(session_id)
raw = request.form.get("stages_json", "[]")
try:
stages = json.loads(raw)
if not isinstance(stages, list):
stages = []
except (json.JSONDecodeError, ValueError):
stages = []
s.prs_stages = stages
db.session.commit()
flash(_("Stages saved."), "success")
return redirect(url_for("sessions.detail", session_id=session_id))
@sessions_bp.route("/<int:session_id>/dope-card")
def dope_card(session_id: int):
s = db.session.get(ShootingSession, session_id)
if s is None:
abort(404)
is_owner = current_user.is_authenticated and s.user_id == current_user.id
if not s.is_public and not is_owner:
abort(403)
if s.session_type != "prs":
abort(400)
from analyzer.dope_card import generate_dope_card
from flask import make_response
stages = s.prs_stages or []
pdf_bytes = generate_dope_card(s, stages)
resp = make_response(pdf_bytes)
resp.headers["Content-Type"] = "application/pdf"
fname = f"dope_card_{s.session_date.isoformat()}.pdf"
resp.headers["Content-Disposition"] = f'inline; filename="{fname}"'
return resp
# ---------------------------------------------------------------------------
# CSV upload
# ---------------------------------------------------------------------------
@@ -206,7 +345,7 @@ def upload_csv(session_id: int):
csv_file = request.files.get("csv_file")
if not csv_file or not csv_file.filename:
flash("No CSV file selected.", "error")
flash(_("No CSV file selected."), "error")
return redirect(url_for("sessions.detail", session_id=session_id))
from analyzer.parser import parse_csv
@@ -240,7 +379,7 @@ def upload_csv(session_id: int):
session_id=session_id,
is_public=s.is_public if s else False,
)
flash("CSV analysed and linked to this session.", "success")
flash(_("CSV analysed and linked to this session."), "success")
return redirect(url_for("sessions.detail", session_id=session_id))
@@ -255,7 +394,7 @@ def upload_photo(session_id: int):
photo_file = request.files.get("photo")
if not photo_file or not photo_file.filename:
flash("No photo selected.", "error")
flash(_("No photo selected."), "error")
return redirect(url_for("sessions.detail", session_id=session_id))
try:
@@ -268,7 +407,7 @@ def upload_photo(session_id: int):
photo = SessionPhoto(session_id=session_id, photo_path=photo_path, caption=caption)
db.session.add(photo)
db.session.commit()
flash("Photo added.", "success")
flash(_("Photo added."), "success")
return redirect(url_for("sessions.detail", session_id=session_id))
@@ -282,7 +421,7 @@ def delete_photo(session_id: int, photo_id: int):
_remove_file(photo.photo_path)
db.session.delete(photo)
db.session.commit()
flash("Photo deleted.", "success")
flash(_("Photo deleted."), "success")
return redirect(url_for("sessions.detail", session_id=session_id))

View File

@@ -3,6 +3,8 @@ set -e
mkdir -p /app/storage/csvs /app/storage/pdfs /app/storage/equipment_photos
pybabel compile -d translations 2>/dev/null || true
flask db upgrade
exec python -m gunicorn --bind 0.0.0.0:5000 --workers 2 "app:create_app()"

View File

@@ -1,4 +1,5 @@
from authlib.integrations.flask_client import OAuth
from flask_babel import Babel, lazy_gettext as _l
from flask_jwt_extended import JWTManager
from flask_login import LoginManager
from flask_migrate import Migrate
@@ -9,6 +10,7 @@ login_manager = LoginManager()
migrate = Migrate()
oauth = OAuth()
jwt = JWTManager()
babel = Babel()
login_manager.login_view = "auth.login"
login_manager.login_message = "Please log in to access this page."
login_manager.login_message = _l("Please log in to access this page.")

View File

@@ -0,0 +1,36 @@
"""prs_stages and drop competition fields
Revision ID: bf96ceb7f076
Revises: edf627601b3d
Create Date: 2026-03-18 15:41:28.882592
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'bf96ceb7f076'
down_revision = 'edf627601b3d'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('shooting_sessions', schema=None) as batch_op:
batch_op.add_column(sa.Column('prs_stages', sa.JSON(), nullable=True))
batch_op.drop_column('competition_stage')
batch_op.drop_column('competition_division')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('shooting_sessions', schema=None) as batch_op:
batch_op.add_column(sa.Column('competition_division', sa.VARCHAR(length=100), autoincrement=False, nullable=True))
batch_op.add_column(sa.Column('competition_stage', sa.VARCHAR(length=255), autoincrement=False, nullable=True))
batch_op.drop_column('prs_stages')
# ### end Alembic commands ###

View File

@@ -0,0 +1,49 @@
"""session_type and analysis_grouping_fields
Revision ID: edf627601b3d
Revises: d46dc696b3c3
Create Date: 2026-03-18 14:12:25.811674
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'edf627601b3d'
down_revision = 'd46dc696b3c3'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('analyses', schema=None) as batch_op:
batch_op.add_column(sa.Column('grouping_outlier_factor', sa.Double(), nullable=True))
batch_op.add_column(sa.Column('grouping_manual_splits', sa.JSON(), nullable=True))
with op.batch_alter_table('shooting_sessions', schema=None) as batch_op:
batch_op.add_column(sa.Column('session_type', sa.String(length=50), nullable=True))
batch_op.add_column(sa.Column('competition_stage', sa.String(length=255), nullable=True))
batch_op.add_column(sa.Column('competition_division', sa.String(length=100), nullable=True))
batch_op.add_column(sa.Column('shooting_position', sa.String(length=100), nullable=True))
# Backfill existing sessions as long_range precision
op.execute("UPDATE shooting_sessions SET session_type = 'long_range' WHERE session_type IS NULL")
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('shooting_sessions', schema=None) as batch_op:
batch_op.drop_column('shooting_position')
batch_op.drop_column('competition_division')
batch_op.drop_column('competition_stage')
batch_op.drop_column('session_type')
with op.batch_alter_table('analyses', schema=None) as batch_op:
batch_op.drop_column('grouping_manual_splits')
batch_op.drop_column('grouping_outlier_factor')
# ### end Alembic commands ###

View File

@@ -118,6 +118,9 @@ class ShootingSession(db.Model):
ammo_weight_gr: Mapped[float | None] = mapped_column(Numeric(7, 2))
ammo_lot: Mapped[str | None] = mapped_column(String(80))
notes: Mapped[str | None] = mapped_column(Text)
session_type: Mapped[str | None] = mapped_column(String(50))
shooting_position: Mapped[str | None] = mapped_column(String(100))
prs_stages: Mapped[list | None] = mapped_column(db.JSON)
is_public: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
@@ -151,6 +154,8 @@ class Analysis(db.Model):
group_stats: Mapped[list] = mapped_column(db.JSON, nullable=False)
shot_count: Mapped[int] = mapped_column(Integer, nullable=False)
group_count: Mapped[int] = mapped_column(Integer, nullable=False)
grouping_outlier_factor: Mapped[float | None] = mapped_column(Double)
grouping_manual_splits: Mapped[list | None] = mapped_column(db.JSON)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
user: Mapped["User"] = relationship("User", back_populates="analyses")

View File

@@ -1,4 +1,5 @@
Flask>=3.0
Flask-Babel>=3.0
python-dotenv>=1.0
Flask-SQLAlchemy>=3.1
Flask-Migrate>=4.0

View File

@@ -6,51 +6,51 @@
<div>
<div style="font-size:0.82rem;color:#888;margin-bottom:.3rem;">
{% if analysis.session_id %}
<a href="{{ url_for('sessions.detail', session_id=analysis.session_id) }}">Session</a> &rsaquo;
<a href="{{ url_for('sessions.detail', session_id=analysis.session_id) }}">{{ _('Session') }}</a> &rsaquo;
{% else %}
<a href="{{ url_for('dashboard.index') }}">Dashboard</a> &rsaquo;
<a href="{{ url_for('dashboard.index') }}">{{ _('Dashboard') }}</a> &rsaquo;
{% endif %}
Analysis
{{ _('Analysis') }}
</div>
<h1 style="margin:0;">{{ analysis.title }}</h1>
<div style="font-size:0.85rem;color:#888;margin-top:.3rem;">
{{ analysis.created_at.strftime('%d %b %Y') }}
&nbsp;&middot;&nbsp; {{ analysis.shot_count }} shot(s)
&nbsp;&middot;&nbsp; {{ analysis.group_count }} group(s)
&nbsp;&middot;&nbsp; {{ analysis.shot_count }} {{ _('shot(s)') }}
&nbsp;&middot;&nbsp; {{ analysis.group_count }} {{ _('group(s)') }}
</div>
</div>
<div style="display:flex;gap:.75rem;align-items:center;flex-wrap:wrap;">
{% if has_pdf %}
<a href="{{ url_for('analyses.download_pdf', analysis_id=analysis.id) }}"
style="background:#1f77b4;color:#fff;border-radius:4px;padding:0.5rem 1.1rem;font-size:0.9rem;text-decoration:none;">
&#8659; Download PDF report
&#8659; {{ _('Download PDF report') }}
</a>
{% endif %}
{% if current_user.is_authenticated and current_user.id == analysis.user_id %}
<form method="post" action="{{ url_for('analyses.delete', analysis_id=analysis.id) }}"
onsubmit="return confirm('Delete this analysis? The CSV and PDF will be permanently removed.');">
onsubmit="return confirm('{{ _('Delete this analysis? The CSV and PDF will be permanently removed.') | e }}');">
<button type="submit"
style="background:#fff0f0;color:#c0392b;border:1px solid #f5c6c6;border-radius:4px;padding:0.5rem 1.1rem;font-size:0.9rem;cursor:pointer;">
Delete
{{ _('Delete') }}
</button>
</form>
{% endif %}
<a href="{{ url_for('analyze') }}" style="font-size:0.9rem;color:#666;">&#8592; New analysis</a>
<a href="{{ url_for('analyze') }}" style="font-size:0.9rem;color:#666;">&#8592; {{ _('New analysis') }}</a>
</div>
</div>
<h2>Overall Statistics</h2>
<h2>{{ _('Overall Statistics') }}</h2>
<table style="max-width:480px;">
<thead>
<tr><th>Metric</th><th>Value</th></tr>
<tr><th>{{ _('Metric') }}</th><th>{{ _('Value') }}</th></tr>
</thead>
<tbody>
<tr><td>Total shots</td><td>{{ overall.count }}</td></tr>
<tr><td>Min speed</td><td>{{ "%.4f"|format(overall.min_speed) }}</td></tr>
<tr><td>Max speed</td><td>{{ "%.4f"|format(overall.max_speed) }}</td></tr>
<tr><td>Mean speed</td><td>{{ "%.4f"|format(overall.mean_speed) }}</td></tr>
<tr><td>{{ _('Total shots') }}</td><td>{{ overall.count }}</td></tr>
<tr><td>{{ _('Min speed') }}</td><td>{{ "%.4f"|format(overall.min_speed) }}</td></tr>
<tr><td>{{ _('Max speed') }}</td><td>{{ "%.4f"|format(overall.max_speed) }}</td></tr>
<tr><td>{{ _('Mean speed') }}</td><td>{{ "%.4f"|format(overall.mean_speed) }}</td></tr>
<tr>
<td>Std dev (speed)</td>
<td>{{ _('Std dev (speed)') }}</td>
<td>
{% if overall.std_speed is not none %}{{ "%.4f"|format(overall.std_speed) }}{% else %}&ndash;{% endif %}
</td>
@@ -61,24 +61,24 @@
<img class="chart-img" src="data:image/png;base64,{{ overview_chart }}"
alt="Avg speed and std dev per group" style="max-width:600px;margin:1rem 0 1.5rem;">
<h2>Groups &mdash; {{ groups_display|length }} group(s) detected</h2>
<h2>{{ _('Groups') }} &mdash; {{ groups_display|length }} {{ _('group(s) detected') }}</h2>
{% for stat, chart_b64 in groups_display %}
<div class="group-section">
<h3>Group {{ stat.group_index }}</h3>
<h3>{{ _('Group %(n)s', n=stat.group_index) }}</h3>
<div class="group-meta">
{{ stat.time_start }} &ndash; {{ stat.time_end }} &nbsp;|&nbsp; {{ stat.count }} shot(s)
{{ stat.time_start }} &ndash; {{ stat.time_end }} &nbsp;|&nbsp; {{ stat.count }} {{ _('shot(s)') }}
</div>
<table style="max-width:480px;">
<thead>
<tr><th>Metric</th><th>Value</th></tr>
<tr><th>{{ _('Metric') }}</th><th>{{ _('Value') }}</th></tr>
</thead>
<tbody>
<tr><td>Min speed</td><td>{{ "%.4f"|format(stat.min_speed) }}</td></tr>
<tr><td>Max speed</td><td>{{ "%.4f"|format(stat.max_speed) }}</td></tr>
<tr><td>Mean speed</td><td>{{ "%.4f"|format(stat.mean_speed) }}</td></tr>
<tr><td>{{ _('Min speed') }}</td><td>{{ "%.4f"|format(stat.min_speed) }}</td></tr>
<tr><td>{{ _('Max speed') }}</td><td>{{ "%.4f"|format(stat.max_speed) }}</td></tr>
<tr><td>{{ _('Mean speed') }}</td><td>{{ "%.4f"|format(stat.mean_speed) }}</td></tr>
<tr>
<td>Std dev (speed)</td>
<td>{{ _('Std dev (speed)') }}</td>
<td>
{% if stat.std_speed is not none %}{{ "%.4f"|format(stat.std_speed) }}{% else %}&ndash;{% endif %}
</td>

View File

@@ -1,22 +1,22 @@
{% extends "base.html" %}
{% block title %}Login — Ballistic Analyzer{% endblock %}
{% block title %}{{ _('Sign in') }} — Ballistic Analyzer{% endblock %}
{% block content %}
<h1>Sign in</h1>
<h1>{{ _('Sign in') }}</h1>
<form method="post" action="{{ url_for('auth.login') }}" style="max-width:360px;margin-bottom:1.5rem;">
<div style="margin-bottom:1rem;">
<label style="display:block;font-size:0.88rem;font-weight:600;color:#444;margin-bottom:0.3rem;">Email</label>
<label style="display:block;font-size:0.88rem;font-weight:600;color:#444;margin-bottom:0.3rem;">{{ _('Email') }}</label>
<input type="email" name="email" value="{{ prefill_email or '' }}" required autocomplete="email"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div style="margin-bottom:1.25rem;">
<label style="display:block;font-size:0.88rem;font-weight:600;color:#444;margin-bottom:0.3rem;">Password</label>
<label style="display:block;font-size:0.88rem;font-weight:600;color:#444;margin-bottom:0.3rem;">{{ _('Password') }}</label>
<input type="password" name="password" required autocomplete="current-password"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<button type="submit"
style="width:100%;background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:0.65rem;font-size:0.95rem;cursor:pointer;">
Sign in
{{ _('Sign in') }}
</button>
</form>
@@ -24,18 +24,18 @@
<form method="post" action="{{ url_for('auth.resend_confirmation') }}" style="margin-bottom:1.5rem;">
<input type="hidden" name="email" value="{{ resend_email }}">
<button type="submit" class="btn-link" style="color:#1f77b4;font-size:0.88rem;">
Resend confirmation email
{{ _('Resend confirmation email') }}
</button>
</form>
{% endif %}
<p style="font-size:0.9rem;color:#555;margin-bottom:1.5rem;">
Don't have an account? <a href="{{ url_for('auth.register') }}">Create one</a>
{{ _("Don't have an account?") }} <a href="{{ url_for('auth.register') }}">{{ _('Create one') }}</a>
</p>
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1.5rem;max-width:360px;">
<hr style="flex:1;border:none;border-top:1px solid #ddd;">
<span style="font-size:0.8rem;color:#999;">or continue with</span>
<span style="font-size:0.8rem;color:#999;">{{ _('or continue with') }}</span>
<hr style="flex:1;border:none;border-top:1px solid #ddd;">
</div>
@@ -48,7 +48,7 @@
<path d="M3.964 10.71A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.042l3.007-2.332z" fill="#FBBC05"/>
<path d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.958L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58z" fill="#EA4335"/>
</svg>
Continue with Google
{{ _('Continue with Google') }}
</a>
<a href="{{ url_for('auth.login_github') }}"
@@ -56,7 +56,7 @@
<svg width="17" height="17" viewBox="0 0 98 96" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#24292f"/>
</svg>
Continue with GitHub
{{ _('Continue with GitHub') }}
</a>
</div>
{% endblock %}

View File

@@ -1,10 +1,10 @@
{% extends "base.html" %}
{% block title %}Profile — The Shooter's Network{% endblock %}
{% block title %}{{ _('Profile') }} — The Shooter's Network{% endblock %}
{% block content %}
<h1>Profile</h1>
<h1>{{ _('Profile') }}</h1>
{# ---- Avatar + display name ---- #}
<h2>Account</h2>
<h2>{{ _('Account') }}</h2>
<form method="post" action="{{ url_for('auth.profile') }}"
enctype="multipart/form-data"
style="max-width:480px;">
@@ -22,15 +22,15 @@
{% endif %}
<div>
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">
Profile picture
{{ _('Profile picture') }}
</label>
<input type="file" name="avatar" accept="image/*" style="font-size:0.9rem;">
<div style="font-size:0.78rem;color:#aaa;margin-top:.2rem;">JPEG/PNG, max 1200 px, auto-resized.</div>
<div style="font-size:0.78rem;color:#aaa;margin-top:.2rem;">{{ _('JPEG/PNG, max 1200 px, auto-resized.') }}</div>
</div>
</div>
<div style="margin-bottom:1rem;">
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">Display name</label>
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">{{ _('Display name') }}</label>
<input type="text" name="display_name"
value="{{ current_user.display_name or '' }}"
required
@@ -38,13 +38,13 @@
</div>
<div style="margin-bottom:1rem;">
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">Bio</label>
<textarea name="bio" rows="4" placeholder="Tell others a bit about yourself…"
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">{{ _('Bio') }}</label>
<textarea name="bio" rows="4" placeholder="{{ _('Tell others a bit about yourself…') }}"
style="width:100%;padding:.55rem .75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;resize:vertical;">{{ current_user.bio or '' }}</textarea>
</div>
<div style="margin-bottom:1rem;">
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">Email</label>
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">{{ _('Email') }}</label>
<input type="text" value="{{ current_user.email }}" disabled
style="width:100%;padding:.55rem .75rem;border:1px solid #e0e0e0;border-radius:4px;font-size:0.95rem;background:#f5f5f5;color:#888;">
<div style="font-size:0.78rem;color:#aaa;margin-top:.2rem;">
@@ -57,49 +57,49 @@
<input type="checkbox" name="show_equipment_public"
{% if current_user.show_equipment_public %}checked{% endif %}
style="width:1rem;height:1rem;">
Show my equipment on my public profile
{{ _('Show my equipment on my public profile') }}
</label>
</div>
<div style="display:flex;align-items:center;gap:1.5rem;flex-wrap:wrap;">
<button type="submit"
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.6rem 1.5rem;font-size:.95rem;cursor:pointer;">
Save changes
{{ _('Save changes') }}
</button>
<a href="{{ url_for('public_profile', user_id=current_user.id) }}"
style="font-size:0.9rem;color:#1f77b4;" target="_blank">
View my public profile &rarr;
{{ _('View my public profile →') }}
</a>
</div>
</form>
{# ---- Change password (local accounts only) ---- #}
{% if current_user.provider == 'local' %}
<h2>Change password</h2>
<h2>{{ _('Change password') }}</h2>
<form method="post" action="{{ url_for('auth.profile') }}"
style="max-width:480px;">
<input type="hidden" name="action" value="change_password">
<div style="margin-bottom:1rem;">
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">Current password</label>
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">{{ _('Current password') }}</label>
<input type="password" name="current_password" required
style="width:100%;padding:.55rem .75rem;border:1px solid #ccc;border-radius:4px;font-size:.95rem;">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem;">
<div>
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">New password</label>
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">{{ _('New password') }}</label>
<input type="password" name="new_password" required minlength="8"
style="width:100%;padding:.55rem .75rem;border:1px solid #ccc;border-radius:4px;font-size:.95rem;">
</div>
<div>
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">Confirm</label>
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">{{ _('Confirm') }}</label>
<input type="password" name="confirm_password" required minlength="8"
style="width:100%;padding:.55rem .75rem;border:1px solid #ccc;border-radius:4px;font-size:.95rem;">
</div>
</div>
<button type="submit"
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.6rem 1.5rem;font-size:.95rem;cursor:pointer;">
Change password
{{ _('Change password') }}
</button>
</form>
{% endif %}

View File

@@ -15,7 +15,7 @@
<div>
<h1 style="margin:0 0 .25rem;">{{ profile_user.display_name or profile_user.email.split('@')[0] }}</h1>
<div style="font-size:0.85rem;color:#888;">
Member since {{ profile_user.created_at.strftime('%B %Y') }}
{{ _('Member since %(date)s', date=profile_user.created_at.strftime('%B %Y')) }}
</div>
{% if profile_user.bio %}
<p style="margin-top:.75rem;color:#444;white-space:pre-wrap;max-width:600px;">{{ profile_user.bio }}</p>
@@ -24,12 +24,12 @@
</div>
{# ---- Public Sessions ---- #}
<h2>Sessions{% if public_sessions %} ({{ public_sessions|length }}){% endif %}</h2>
<h2>{{ _('Sessions') }}{% if public_sessions %} ({{ public_sessions|length }}){% endif %}</h2>
{% if public_sessions %}
<table style="margin-bottom:1.5rem;">
<thead>
<tr><th>Session</th><th>Location</th><th>Distance</th></tr>
<tr><th>{{ _('Session') }}</th><th>{{ _('Location') }}</th><th>{{ _('Distance') }}</th></tr>
</thead>
<tbody>
{% for s in public_sessions %}
@@ -46,16 +46,16 @@
</tbody>
</table>
{% else %}
<p style="color:#888;margin-bottom:1.5rem;">No public sessions yet.</p>
<p style="color:#888;margin-bottom:1.5rem;">{{ _('No public sessions yet.') }}</p>
{% endif %}
{# ---- Equipment (optional) ---- #}
{% if equipment is not none %}
<h2>Equipment</h2>
<h2>{{ _('Equipment') }}</h2>
{% if equipment %}
<table style="margin-bottom:1.5rem;">
<thead>
<tr><th>Name</th><th>Category</th><th>Brand / Model</th><th>Caliber</th></tr>
<tr><th>{{ _('Name') }}</th><th>{{ _('Category') }}</th><th>{{ _('Brand / Model') }}</th><th>{{ _('Caliber') }}</th></tr>
</thead>
<tbody>
{% for item in equipment %}
@@ -73,7 +73,7 @@
</tbody>
</table>
{% else %}
<p style="color:#888;margin-bottom:1.5rem;">No equipment listed.</p>
<p style="color:#888;margin-bottom:1.5rem;">{{ _('No equipment listed.') }}</p>
{% endif %}
{% endif %}

View File

@@ -1,33 +1,33 @@
{% extends "base.html" %}
{% block title %}Create account — Ballistic Analyzer{% endblock %}
{% block title %}{{ _('Create account') }} — Ballistic Analyzer{% endblock %}
{% block content %}
<h1>Create account</h1>
<h1>{{ _('Create account') }}</h1>
<form method="post" action="{{ url_for('auth.register') }}" style="max-width:360px;">
<div style="margin-bottom:1rem;">
<label style="display:block;font-size:0.88rem;font-weight:600;color:#444;margin-bottom:0.3rem;">Email</label>
<label style="display:block;font-size:0.88rem;font-weight:600;color:#444;margin-bottom:0.3rem;">{{ _('Email') }}</label>
<input type="email" name="email" value="{{ prefill_email or '' }}" required autocomplete="email"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div style="margin-bottom:1rem;">
<label style="display:block;font-size:0.88rem;font-weight:600;color:#444;margin-bottom:0.3rem;">Password
<span style="font-weight:400;color:#888;">(min. 8 characters)</span>
<label style="display:block;font-size:0.88rem;font-weight:600;color:#444;margin-bottom:0.3rem;">{{ _('Password') }}
<span style="font-weight:400;color:#888;">{{ _('(min. 8 characters)') }}</span>
</label>
<input type="password" name="password" required autocomplete="new-password" minlength="8"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div style="margin-bottom:1.5rem;">
<label style="display:block;font-size:0.88rem;font-weight:600;color:#444;margin-bottom:0.3rem;">Confirm password</label>
<label style="display:block;font-size:0.88rem;font-weight:600;color:#444;margin-bottom:0.3rem;">{{ _('Confirm password') }}</label>
<input type="password" name="confirm_password" required autocomplete="new-password"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<button type="submit"
style="width:100%;background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:0.65rem;font-size:0.95rem;cursor:pointer;">
Create account
{{ _('Create account') }}
</button>
</form>
<p style="font-size:0.9rem;color:#555;margin-top:1.25rem;">
Already have an account? <a href="{{ url_for('auth.login') }}">Sign in</a>
{{ _('Already have an account?') }} <a href="{{ url_for('auth.login') }}">{{ _('Sign in') }}</a>
</p>
{% endblock %}

View File

@@ -23,6 +23,7 @@
padding: 0 1.5rem;
height: 52px;
gap: 1rem;
position: relative;
}
.nav-brand {
font-weight: 700;
@@ -57,6 +58,57 @@
gap: 0.75rem;
flex-shrink: 0;
}
/* ── Hamburger (hidden on desktop) ── */
.nav-hamburger {
display: none;
background: none;
border: none;
color: #fff;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
padding: 0.25rem 0.4rem;
}
/* ── Mobile menu panel ── */
.nav-mobile-menu {
display: none;
position: absolute;
top: 52px;
left: 0; right: 0;
background: #1a1a2e;
border-top: 1px solid rgba(255,255,255,.12);
padding: 0.75rem 1.5rem 1rem;
z-index: 200;
flex-direction: column;
gap: 0;
}
.nav-mobile-menu a,
.nav-mobile-menu button {
display: block;
width: 100%;
text-align: left;
color: #c8cfe0;
text-decoration: none;
font-size: 0.95rem;
padding: 0.6rem 0;
border-bottom: 1px solid rgba(255,255,255,.07);
background: none;
border-left: none;
border-right: none;
border-top: none;
cursor: pointer;
font-family: inherit;
}
.nav-mobile-menu a:last-child,
.nav-mobile-menu button:last-child { border-bottom: none; }
.nav-mobile-menu a:hover,
.nav-mobile-menu button:hover { color: #fff; }
.nav.open .nav-mobile-menu { display: flex; }
@media (max-width: 640px) {
.nav-links { display: none; }
.nav-right { display: none; }
.nav-hamburger { display: block; }
}
/* ── User dropdown ── */
.nav-dropdown { position: relative; }
.nav-user-btn {
@@ -189,19 +241,30 @@
<body>
{% block body %}
<nav class="nav">
<nav class="nav" id="mainNav">
<a href="/" class="nav-brand">The Shooter's Network</a>
<div class="nav-links">
{% if current_user.is_authenticated %}
<a href="{{ url_for('analyze') }}">New Analysis</a>
<a href="{{ url_for('equipment.index') }}">Equipment</a>
<a href="{{ url_for('sessions.index') }}">Sessions</a>
<a href="{{ url_for('dashboard.index') }}">Dashboard</a>
<a href="{{ url_for('analyze') }}">{{ _('New Analysis') }}</a>
<a href="{{ url_for('equipment.index') }}">{{ _('Equipment') }}</a>
<a href="{{ url_for('sessions.index') }}">{{ _('Sessions') }}</a>
<a href="{{ url_for('dashboard.index') }}">{{ _('Dashboard') }}</a>
{% endif %}
</div>
<div class="nav-right">
{# Language switcher #}
<div class="nav-dropdown" id="langDropdown">
<button class="nav-user-btn" onclick="toggleLangDropdown(event)" style="padding:.2rem .55rem;gap:.35rem;font-size:1.1rem;">
{% if current_lang == 'fr' %}🇫🇷{% elif current_lang == 'de' %}🇩🇪{% else %}🇬🇧{% endif %}<span class="caret">&#9660;</span>
</button>
<div class="nav-dd-menu" style="min-width:130px;">
<a href="{{ url_for('set_lang', lang='en') }}">🇬🇧 English</a>
<a href="{{ url_for('set_lang', lang='fr') }}">🇫🇷 Français</a>
<a href="{{ url_for('set_lang', lang='de') }}">🇩🇪 Deutsch</a>
</div>
</div>
{% if current_user.is_authenticated %}
<div class="nav-dropdown" id="userDropdown">
<button class="nav-user-btn" onclick="toggleDropdown(event)">
@@ -215,21 +278,46 @@
<span class="caret">&#9660;</span>
</button>
<div class="nav-dd-menu">
<a href="{{ url_for('auth.profile') }}">&#128100;&ensp;Profile</a>
<a href="{{ url_for('auth.profile') }}">&#128100;&ensp;{{ _('Profile') }}</a>
<hr>
<form method="post" action="{{ url_for('auth.logout') }}">
<button type="submit">&#8594;&ensp;Logout</button>
<button type="submit">&#8594;&ensp;{{ _('Logout') }}</button>
</form>
</div>
</div>
{% else %}
<a href="{{ url_for('auth.login') }}" style="color:#c8cfe0;font-size:0.9rem;text-decoration:none;">Login</a>
<a href="{{ url_for('auth.login') }}" style="color:#c8cfe0;font-size:0.9rem;text-decoration:none;">{{ _('Login') }}</a>
<a href="{{ url_for('auth.register') }}"
style="background:#1f77b4;color:#fff;padding:0.35rem 0.9rem;border-radius:4px;font-size:0.88rem;text-decoration:none;">
Join free
{{ _('Join free') }}
</a>
{% endif %}
</div>
{# Hamburger — only visible on mobile #}
<button class="nav-hamburger" onclick="toggleMobileNav(event)" aria-label="Menu">&#9776;</button>
{# Mobile menu panel #}
<div class="nav-mobile-menu">
{% if current_user.is_authenticated %}
<a href="{{ url_for('analyze') }}">{{ _('New Analysis') }}</a>
<a href="{{ url_for('equipment.index') }}">{{ _('Equipment') }}</a>
<a href="{{ url_for('sessions.index') }}">{{ _('Sessions') }}</a>
<a href="{{ url_for('dashboard.index') }}">{{ _('Dashboard') }}</a>
<a href="{{ url_for('auth.profile') }}">{{ _('Profile') }}</a>
<form method="post" action="{{ url_for('auth.logout') }}" style="padding:0;border:none;">
<button type="submit">{{ _('Logout') }}</button>
</form>
{% else %}
<a href="{{ url_for('auth.login') }}">{{ _('Login') }}</a>
<a href="{{ url_for('auth.register') }}">{{ _('Join free') }}</a>
{% endif %}
<div style="padding:.5rem 0;border-top:1px solid rgba(255,255,255,.1);margin-top:.25rem;display:flex;gap:1rem;">
<a href="{{ url_for('set_lang', lang='en') }}" style="font-size:1.1rem;text-decoration:none;color:#fff;">🇬🇧</a>
<a href="{{ url_for('set_lang', lang='fr') }}" style="font-size:1.1rem;text-decoration:none;color:#fff;">🇫🇷</a>
<a href="{{ url_for('set_lang', lang='de') }}" style="font-size:1.1rem;text-decoration:none;color:#fff;">🇩🇪</a>
</div>
</div>
</nav>
<script>
@@ -237,9 +325,21 @@
e.stopPropagation();
document.getElementById('userDropdown').classList.toggle('open');
}
function toggleLangDropdown(e) {
e.stopPropagation();
document.getElementById('langDropdown').classList.toggle('open');
}
function toggleMobileNav(e) {
e.stopPropagation();
document.getElementById('mainNav').classList.toggle('open');
}
document.addEventListener('click', function() {
var d = document.getElementById('userDropdown');
if (d) d.classList.remove('open');
var l = document.getElementById('langDropdown');
if (l) l.classList.remove('open');
var n = document.getElementById('mainNav');
if (n) n.classList.remove('open');
});
</script>

View File

@@ -1,37 +1,37 @@
{% extends "base.html" %}
{% block title %}Dashboard — The Shooter's Network{% endblock %}
{% block title %}{{ _('Dashboard') }} — The Shooter's Network{% endblock %}
{% block content %}
<h1>Dashboard</h1>
<h1>{{ _('Dashboard') }}</h1>
<p style="color:#555;margin-bottom:2rem;">
Welcome back, <strong>{{ current_user.display_name or current_user.email }}</strong>.
{{ _('Welcome back, %(name)s.', name=(current_user.display_name or current_user.email)) }}
</p>
<div style="display:flex;gap:1.5rem;margin-bottom:2.5rem;flex-wrap:wrap;">
<a href="{{ url_for('sessions.new') }}"
style="background:#1a1a2e;color:#fff;padding:0.5rem 1.2rem;border-radius:4px;font-size:0.92rem;text-decoration:none;">
+ New session
{{ _('+ New session') }}
</a>
<a href="{{ url_for('equipment.new') }}"
style="background:#f0f4ff;color:#1a1a2e;padding:0.5rem 1.2rem;border-radius:4px;font-size:0.92rem;text-decoration:none;border:1px solid #c0d0f0;">
+ Add equipment
{{ _('+ Add equipment') }}
</a>
<a href="{{ url_for('analyze') }}"
style="background:#f0f4ff;color:#1a1a2e;padding:0.5rem 1.2rem;border-radius:4px;font-size:0.92rem;text-decoration:none;border:1px solid #c0d0f0;">
New analysis
{{ _('New analysis') }}
</a>
</div>
<h2>Recent Analyses</h2>
<h2>{{ _('Recent Analyses') }}</h2>
{% if analyses %}
<table>
<thead>
<tr>
<th>Title</th>
<th>Date</th>
<th>Shots</th>
<th>Groups</th>
<th>Visibility</th>
<th>{{ _('Title') }}</th>
<th>{{ _('Date') }}</th>
<th>{{ _('Shots') }}</th>
<th>{{ _('Groups') }}</th>
<th>{{ _('Visibility') }}</th>
</tr>
</thead>
<tbody>
@@ -42,7 +42,7 @@
<td>{{ a.shot_count }}</td>
<td>{{ a.group_count }}</td>
<td style="color:{% if a.is_public %}#27ae60{% else %}#888{% endif %};font-size:0.88rem;">
{{ 'Public' if a.is_public else 'Private' }}
{{ _('Public') if a.is_public else _('Private') }}
</td>
</tr>
{% endfor %}
@@ -50,18 +50,18 @@
</table>
{% else %}
<p style="color:#888;margin-top:1rem;">
No analyses yet. <a href="{{ url_for('analyze') }}">Upload a CSV file</a> to get started — it will be saved here automatically.
{{ _('No analyses yet.') }} <a href="{{ url_for('analyze') }}">{{ _('Upload a CSV file') }}</a> {{ _('to get started — it will be saved here automatically.') }}
</p>
{% endif %}
<div style="display:flex;gap:2rem;margin-top:2.5rem;flex-wrap:wrap;">
<div style="flex:1;min-width:180px;padding:1.25rem;border:1px solid #e0e0e0;border-radius:6px;">
<div style="font-size:0.8rem;color:#888;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.4rem;">Equipment</div>
<a href="{{ url_for('equipment.index') }}">Manage your rifles, scopes &amp; gear &rarr;</a>
<div style="font-size:0.8rem;color:#888;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.4rem;">{{ _('Equipment') }}</div>
<a href="{{ url_for('equipment.index') }}">{{ _('Manage your rifles, scopes & gear →') }}</a>
</div>
<div style="flex:1;min-width:180px;padding:1.25rem;border:1px solid #e0e0e0;border-radius:6px;">
<div style="font-size:0.8rem;color:#888;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.4rem;">Sessions</div>
<a href="{{ url_for('sessions.index') }}">View your shooting sessions &rarr;</a>
<div style="font-size:0.8rem;color:#888;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.4rem;">{{ _('Sessions') }}</div>
<a href="{{ url_for('sessions.index') }}">{{ _('View your shooting sessions →') }}</a>
</div>
</div>
{% endblock %}

View File

@@ -1,8 +1,8 @@
{% extends "base.html" %}
{% set editing = item is not none %}
{% block title %}{{ 'Edit' if editing else 'Add' }} Equipment — The Shooter's Network{% endblock %}
{% block title %}{{ _('Edit') if editing else _('Add equipment') }} — The Shooter's Network{% endblock %}
{% block content %}
<h1>{{ 'Edit' if editing else 'Add equipment' }}</h1>
<h1>{{ _('Edit') if editing else _('Add equipment') }}</h1>
{% set f = prefill or item %}
@@ -12,7 +12,7 @@
style="max-width:520px;">
<div style="margin-bottom:1rem;">
<label class="field-label">Category *</label>
<label class="field-label">{{ _('Category *') }}</label>
<select name="category" required style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;background:#fff;">
{% for key, label in categories %}
<option value="{{ key }}" {% if (f and f.category == key) or (not f and key == 'rifle') %}selected{% endif %}>{{ label }}</option>
@@ -21,7 +21,7 @@
</div>
<div style="margin-bottom:1rem;">
<label class="field-label">Name *</label>
<label class="field-label">{{ _('Name *') }}</label>
<input type="text" name="name" value="{{ f.name if f else '' }}" required
placeholder="e.g. Tikka T3x, Glock 17"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
@@ -29,13 +29,13 @@
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem;">
<div>
<label class="field-label">Brand</label>
<label class="field-label">{{ _('Brand') }}</label>
<input type="text" name="brand" value="{{ f.brand if f else '' }}"
placeholder="e.g. Tikka, Leupold"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div>
<label class="field-label">Model</label>
<label class="field-label">{{ _('Model') }}</label>
<input type="text" name="model" value="{{ f.model if f else '' }}"
placeholder="e.g. T3x, VX-3HD"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
@@ -45,7 +45,7 @@
<div id="rifle-fields" style="margin-bottom:1rem;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
<div>
<label class="field-label">Caliber</label>
<label class="field-label">{{ _('Caliber') }}</label>
<input type="text" name="caliber" value="{{ f.caliber if f else '' }}"
placeholder="e.g. .308 Win, 6.5 CM"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
@@ -56,13 +56,13 @@
<div id="scope-fields" style="display:none;margin-bottom:1rem;">
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:1rem;">
<div>
<label class="field-label">Magnification</label>
<label class="field-label">{{ _('Magnification') }}</label>
<input type="text" name="magnification" value="{{ f.magnification if f else '' }}"
placeholder="e.g. 3-15x50"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div>
<label class="field-label">Reticle</label>
<label class="field-label">{{ _('Reticle') }}</label>
<select name="reticle" style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;background:#fff;">
<option value=""></option>
<option value="FFP" {% if f and f.reticle == 'FFP' %}selected{% endif %}>FFP (First Focal Plane)</option>
@@ -70,7 +70,7 @@
</select>
</div>
<div>
<label class="field-label">Unit</label>
<label class="field-label">{{ _('Unit') }}</label>
<select name="unit" style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;background:#fff;">
<option value=""></option>
<option value="MOA" {% if f and f.unit == 'MOA' %}selected{% endif %}>MOA</option>
@@ -81,24 +81,24 @@
</div>
<div style="margin-bottom:1rem;">
<label class="field-label">Serial number</label>
<label class="field-label">{{ _('Serial number') }}</label>
<input type="text" name="serial_number" value="{{ f.serial_number if f else '' }}"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div style="margin-bottom:1rem;">
<label class="field-label">Notes</label>
<label class="field-label">{{ _('Notes') }}</label>
<textarea name="notes" rows="3"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;resize:vertical;">{{ f.notes if f else '' }}</textarea>
</div>
<div style="margin-bottom:1.5rem;">
<label class="field-label">Photo</label>
<label class="field-label">{{ _('Photo') }}</label>
{% if editing and item.photo_url %}
<div style="margin-bottom:0.5rem;">
<img src="{{ item.photo_url }}" alt="Current photo"
style="height:80px;border-radius:4px;object-fit:cover;">
<span style="font-size:0.82rem;color:#888;margin-left:0.5rem;">Upload a new one to replace it.</span>
<span style="font-size:0.82rem;color:#888;margin-left:0.5rem;">{{ _('Upload a new one to replace it.') }}</span>
</div>
{% endif %}
<input type="file" name="photo" accept="image/*"
@@ -108,10 +108,10 @@
<div style="display:flex;gap:1rem;align-items:center;">
<button type="submit"
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:0.6rem 1.5rem;font-size:0.95rem;cursor:pointer;">
{{ 'Save changes' if editing else 'Add equipment' }}
{{ _('Save changes') if editing else _('Add equipment') }}
</button>
<a href="{{ url_for('equipment.detail', item_id=item.id) if editing else url_for('equipment.index') }}"
style="font-size:0.9rem;color:#666;">Cancel</a>
style="font-size:0.9rem;color:#666;">{{ _('Cancel') }}</a>
</div>
</form>

View File

@@ -1,11 +1,11 @@
{% extends "base.html" %}
{% block title %}Equipment — The Shooter's Network{% endblock %}
{% block title %}{{ _('Equipment') }} — The Shooter's Network{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:1rem;margin-bottom:1.5rem;">
<h1 style="margin:0;">My Equipment</h1>
<h1 style="margin:0;">{{ _('My Equipment') }}</h1>
<a href="{{ url_for('equipment.new') }}"
style="background:#1a1a2e;color:#fff;padding:0.5rem 1.2rem;border-radius:4px;font-size:0.92rem;text-decoration:none;">
+ Add item
{{ _('+ Add item') }}
</a>
</div>
@@ -39,11 +39,11 @@
<div style="font-size:0.82rem;color:#888;margin-bottom:0.6rem;">{{ item.caliber }}</div>
{% endif %}
<div style="display:flex;gap:0.75rem;margin-top:0.5rem;">
<a href="{{ url_for('equipment.detail', item_id=item.id) }}" style="font-size:0.85rem;">View</a>
<a href="{{ url_for('equipment.edit', item_id=item.id) }}" style="font-size:0.85rem;">Edit</a>
<a href="{{ url_for('equipment.detail', item_id=item.id) }}" style="font-size:0.85rem;">{{ _('View') }}</a>
<a href="{{ url_for('equipment.edit', item_id=item.id) }}" style="font-size:0.85rem;">{{ _('Edit') }}</a>
<form method="post" action="{{ url_for('equipment.delete', item_id=item.id) }}" style="display:inline;"
onsubmit="return confirm('Delete {{ item.name }}?');">
<button type="submit" class="btn-link" style="font-size:0.85rem;color:#e74c3c;">Delete</button>
onsubmit="return confirm('{{ _('Delete %(name)s?', name=item.name) | e }}');">
<button type="submit" class="btn-link" style="font-size:0.85rem;color:#e74c3c;">{{ _('Delete') }}</button>
</form>
</div>
</div>
@@ -55,8 +55,8 @@
{% else %}
<div style="text-align:center;padding:3rem 0;color:#888;">
<div style="font-size:3rem;margin-bottom:1rem;">🔫</div>
<p style="margin-bottom:1rem;">No equipment yet.</p>
<a href="{{ url_for('equipment.new') }}">Add your first item</a>
<p style="margin-bottom:1rem;">{{ _('No equipment yet.') }}</p>
<a href="{{ url_for('equipment.new') }}">{{ _('Add your first item') }}</a>
</div>
{% endif %}
{% endblock %}

View File

@@ -6,13 +6,24 @@
<a href="/" class="nav-brand">The Shooter's Network</a>
<div class="nav-links">
{% if current_user.is_authenticated %}
<a href="{{ url_for('analyze') }}">New Analysis</a>
<a href="{{ url_for('equipment.index') }}">Equipment</a>
<a href="{{ url_for('sessions.index') }}">Sessions</a>
<a href="{{ url_for('dashboard.index') }}">Dashboard</a>
<a href="{{ url_for('analyze') }}">{{ _('New Analysis') }}</a>
<a href="{{ url_for('equipment.index') }}">{{ _('Equipment') }}</a>
<a href="{{ url_for('sessions.index') }}">{{ _('Sessions') }}</a>
<a href="{{ url_for('dashboard.index') }}">{{ _('Dashboard') }}</a>
{% endif %}
</div>
<div class="nav-right">
{# Language switcher #}
<div class="nav-dropdown" id="langDropdown">
<button class="nav-user-btn" onclick="toggleLangDropdown(event)" style="padding:.2rem .55rem;gap:.35rem;font-size:1.1rem;">
{% if current_lang == 'fr' %}🇫🇷{% elif current_lang == 'de' %}🇩🇪{% else %}🇬🇧{% endif %}<span class="caret">&#9660;</span>
</button>
<div class="nav-dd-menu" style="min-width:130px;">
<a href="{{ url_for('set_lang', lang='en') }}">🇬🇧 English</a>
<a href="{{ url_for('set_lang', lang='fr') }}">🇫🇷 Français</a>
<a href="{{ url_for('set_lang', lang='de') }}">🇩🇪 Deutsch</a>
</div>
</div>
{% if current_user.is_authenticated %}
<div class="nav-dropdown" id="userDropdown">
<button class="nav-user-btn" onclick="toggleDropdown(event)">
@@ -23,18 +34,18 @@
<span class="caret">&#9660;</span>
</button>
<div class="nav-dd-menu">
<a href="{{ url_for('auth.profile') }}">&#128100;&ensp;Profile</a>
<a href="{{ url_for('auth.profile') }}">&#128100;&ensp;{{ _('Profile') }}</a>
<hr>
<form method="post" action="{{ url_for('auth.logout') }}">
<button type="submit">&#8594;&ensp;Logout</button>
<button type="submit">&#8594;&ensp;{{ _('Logout') }}</button>
</form>
</div>
</div>
{% else %}
<a href="{{ url_for('auth.login') }}" style="color:#c8cfe0;font-size:0.9rem;text-decoration:none;">Login</a>
<a href="{{ url_for('auth.login') }}" style="color:#c8cfe0;font-size:0.9rem;text-decoration:none;">{{ _('Login') }}</a>
<a href="{{ url_for('auth.register') }}"
style="background:#1f77b4;color:#fff;padding:0.35rem 0.9rem;border-radius:4px;font-size:0.88rem;text-decoration:none;">
Join free
{{ _('Join free') }}
</a>
{% endif %}
</div>
@@ -44,9 +55,15 @@ function toggleDropdown(e) {
e.stopPropagation();
document.getElementById('userDropdown').classList.toggle('open');
}
function toggleLangDropdown(e) {
e.stopPropagation();
document.getElementById('langDropdown').classList.toggle('open');
}
document.addEventListener('click', function() {
var d = document.getElementById('userDropdown');
if (d) d.classList.remove('open');
var l = document.getElementById('langDropdown');
if (l) l.classList.remove('open');
});
</script>
@@ -63,20 +80,20 @@ document.addEventListener('click', function() {
{% if current_user.is_authenticated %}
<a href="{{ url_for('analyze') }}"
style="background:#1f77b4;color:#fff;padding:0.75rem 1.75rem;border-radius:6px;font-size:1rem;font-weight:600;text-decoration:none;">
New Analysis
{{ _('New Analysis') }}
</a>
<a href="{{ url_for('sessions.new') }}"
style="background:transparent;color:#fff;padding:0.75rem 1.75rem;border-radius:6px;font-size:1rem;font-weight:600;text-decoration:none;border:1px solid #4a5568;">
Log a Session
{{ _('Log a Session') }}
</a>
{% else %}
<a href="{{ url_for('auth.register') }}"
style="background:#1f77b4;color:#fff;padding:0.75rem 1.75rem;border-radius:6px;font-size:1rem;font-weight:600;text-decoration:none;">
Get started — free
{{ _('Get started — free') }}
</a>
<a href="{{ url_for('analyze') }}"
style="background:transparent;color:#fff;padding:0.75rem 1.75rem;border-radius:6px;font-size:1rem;font-weight:600;text-decoration:none;border:1px solid #4a5568;">
Try without account
{{ _('Try without account') }}
</a>
{% endif %}
</div>
@@ -87,18 +104,18 @@ document.addEventListener('click', function() {
<div style="max-width:900px;margin:0 auto;display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:1.25rem;">
<div style="background:#fff;padding:1.5rem;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.07);">
<div style="font-size:1.6rem;margin-bottom:0.5rem;">📊</div>
<h3 style="margin:0 0 .35rem;color:#1a1a2e;font-size:1rem;">Ballistic Analysis</h3>
<p style="color:#666;font-size:0.88rem;margin:0;">Upload CSV files from your chronograph and get instant shot-group statistics, velocity charts, and PDF reports.</p>
<h3 style="margin:0 0 .35rem;color:#1a1a2e;font-size:1rem;">{{ _('Ballistic Analysis') }}</h3>
<p style="color:#666;font-size:0.88rem;margin:0;">{{ _('Upload CSV files from your chronograph and get instant shot-group statistics, velocity charts, and PDF reports.') }}</p>
</div>
<div style="background:#fff;padding:1.5rem;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.07);">
<div style="font-size:1.6rem;margin-bottom:0.5rem;">🎯</div>
<h3 style="margin:0 0 .35rem;color:#1a1a2e;font-size:1rem;">Session Tracking</h3>
<p style="color:#666;font-size:0.88rem;margin:0;">Log every range visit with location, weather, rifle, ammo, and distance. All your data in one place.</p>
<h3 style="margin:0 0 .35rem;color:#1a1a2e;font-size:1rem;">{{ _('Session Tracking') }}</h3>
<p style="color:#666;font-size:0.88rem;margin:0;">{{ _('Log every range visit with location, weather, rifle, ammo, and distance. All your data in one place.') }}</p>
</div>
<div style="background:#fff;padding:1.5rem;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.07);">
<div style="font-size:1.6rem;margin-bottom:0.5rem;">🤝</div>
<h3 style="margin:0 0 .35rem;color:#1a1a2e;font-size:1rem;">Community Feed</h3>
<p style="color:#666;font-size:0.88rem;margin:0;">Share your public sessions and see what other shooters are achieving on the range.</p>
<h3 style="margin:0 0 .35rem;color:#1a1a2e;font-size:1rem;">{{ _('Community Feed') }}</h3>
<p style="color:#666;font-size:0.88rem;margin:0;">{{ _('Share your public sessions and see what other shooters are achieving on the range.') }}</p>
</div>
</div>
</section>
@@ -118,7 +135,7 @@ document.addEventListener('click', function() {
<section style="padding:2.5rem 1.5rem 3rem;">
<div style="max-width:960px;margin:0 auto;">
<h2 style="font-size:1.3rem;color:#1a1a2e;margin-bottom:1.25rem;border-bottom:2px solid #e0e0e0;padding-bottom:.4rem;">
Latest sessions
{{ _('Latest sessions') }}
</h2>
{% if public_sessions %}
@@ -149,7 +166,7 @@ document.addEventListener('click', function() {
</div>
{% else %}
<p style="color:#aaa;text-align:center;padding:3rem 0;">
No public sessions yet. Be the first to share one!
{{ _('No public sessions yet. Be the first to share one!') }}
</p>
{% endif %}
</div>

View File

@@ -1,36 +1,36 @@
{% extends "base.html" %}
{% block content %}
<div style="display:flex;align-items:baseline;justify-content:space-between;flex-wrap:wrap;gap:1rem;margin-bottom:1.5rem;">
<h1 style="margin:0;">Analysis Results</h1>
<h1 style="margin:0;">{{ _('Analysis Results') }}</h1>
<div style="display:flex;gap:0.75rem;align-items:center;flex-wrap:wrap;">
<a href="/">&larr; Upload another file</a>
<a href="/">{{ _('← Upload another file') }}</a>
{% if saved_analysis_id %}
<a href="{{ url_for('analyses.detail', analysis_id=saved_analysis_id) }}"
style="font-size:0.9rem;color:#1f77b4;">View saved report &rarr;</a>
style="font-size:0.9rem;color:#1f77b4;">{{ _('View saved report →') }}</a>
{% endif %}
<a href="data:application/pdf;base64,{{ pdf_b64 }}"
download="ballistic_report.pdf"
style="background:#1f77b4;color:#fff;border-radius:4px;padding:0.5rem 1.1rem;font-size:0.9rem;text-decoration:none;">
&#8659; Download PDF report
{{ _('⬙ Download PDF report') }}
</a>
</div>
</div>
<h2>Overall Statistics</h2>
<h2>{{ _('Overall Statistics') }}</h2>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Value</th>
<th>{{ _('Metric') }}</th>
<th>{{ _('Value') }}</th>
</tr>
</thead>
<tbody>
<tr><td>Total shots</td><td>{{ overall.count }}</td></tr>
<tr><td>Min speed</td><td>{{ "%.4f"|format(overall.min_speed) }}</td></tr>
<tr><td>Max speed</td><td>{{ "%.4f"|format(overall.max_speed) }}</td></tr>
<tr><td>Mean speed</td><td>{{ "%.4f"|format(overall.mean_speed) }}</td></tr>
<tr><td>{{ _('Total shots') }}</td><td>{{ overall.count }}</td></tr>
<tr><td>{{ _('Min speed') }}</td><td>{{ "%.4f"|format(overall.min_speed) }}</td></tr>
<tr><td>{{ _('Max speed') }}</td><td>{{ "%.4f"|format(overall.max_speed) }}</td></tr>
<tr><td>{{ _('Mean speed') }}</td><td>{{ "%.4f"|format(overall.mean_speed) }}</td></tr>
<tr>
<td>Std dev (speed)</td>
<td>{{ _('Std dev (speed)') }}</td>
<td>
{% if overall.std_speed is not none %}
{{ "%.4f"|format(overall.std_speed) }}
@@ -45,27 +45,27 @@
<img class="chart-img" src="data:image/png;base64,{{ overview_chart }}"
alt="Avg speed and std dev per group" style="max-width:600px;margin:1rem 0 1.5rem;">
<h2>Groups &mdash; {{ groups_display|length }} group(s) detected</h2>
<h2>{{ _('Groups') }} &mdash; {{ groups_display|length }} {{ _('group(s) detected') }}</h2>
{% for stat, chart_b64 in groups_display %}
<div class="group-section">
<h3>Group {{ stat.group_index }}</h3>
<h3>{{ _('Group %(n)s', n=stat.group_index) }}</h3>
<div class="group-meta">
{{ stat.time_start }} &ndash; {{ stat.time_end }} &nbsp;|&nbsp; {{ stat.count }} shot(s)
</div>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Value</th>
<th>{{ _('Metric') }}</th>
<th>{{ _('Value') }}</th>
</tr>
</thead>
<tbody>
<tr><td>Min speed</td><td>{{ "%.4f"|format(stat.min_speed) }}</td></tr>
<tr><td>Max speed</td><td>{{ "%.4f"|format(stat.max_speed) }}</td></tr>
<tr><td>Mean speed</td><td>{{ "%.4f"|format(stat.mean_speed) }}</td></tr>
<tr><td>{{ _('Min speed') }}</td><td>{{ "%.4f"|format(stat.min_speed) }}</td></tr>
<tr><td>{{ _('Max speed') }}</td><td>{{ "%.4f"|format(stat.max_speed) }}</td></tr>
<tr><td>{{ _('Mean speed') }}</td><td>{{ "%.4f"|format(stat.mean_speed) }}</td></tr>
<tr>
<td>Std dev (speed)</td>
<td>{{ _('Std dev (speed)') }}</td>
<td>
{% if stat.std_speed is not none %}
{{ "%.4f"|format(stat.std_speed) }}

View File

@@ -8,7 +8,14 @@
<a href="{{ url_for('sessions.detail', session_id=session.id) }}">{{ session.label }}</a> &rsaquo;
Annotate
</div>
<h1 style="margin:0;">{{ photo.caption or 'Photo annotation' }}</h1>
<div style="display:flex;align-items:center;justify-content:space-between;gap:1rem;">
<h1 style="margin:0;">{{ photo.caption or 'Photo annotation' }}</h1>
<a href="{{ url_for('sessions.detail', session_id=session.id) }}"
style="background:#f0f4ff;color:#1a1a2e;border:1px solid #c8d4f0;border-radius:4px;
padding:.45rem 1rem;font-size:0.88rem;text-decoration:none;white-space:nowrap;">
✕ Close
</a>
</div>
</div>
<div style="display:flex;gap:1.5rem;align-items:flex-start;">
@@ -49,8 +56,8 @@
<hr style="border:none;border-top:1px solid #e8e8e8;margin-bottom:1rem;">
{# Shooting distance (always visible) #}
<div style="margin-bottom:1rem;">
{# Shooting distance (always visible, pre-filled from session) #}
<div style="margin-bottom:.75rem;">
<label style="display:block;font-size:.82rem;font-weight:600;color:#444;margin-bottom:.3rem;">Shooting distance</label>
<div style="display:flex;gap:.4rem;">
<input type="number" id="shoot-dist" min="1" step="1" placeholder="100"
@@ -63,6 +70,14 @@
</div>
</div>
{# Clean barrel checkbox #}
<div style="margin-bottom:1rem;">
<label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;font-size:0.88rem;color:#444;">
<input type="checkbox" id="clean-barrel" style="width:1rem;height:1rem;">
Clean barrel (first shot)
</label>
</div>
<hr style="border:none;border-top:1px solid #e8e8e8;margin-bottom:1rem;">
{# Step 0: Reference line #}
@@ -117,7 +132,7 @@
<div id="panel-3" class="step-panel" style="display:none;">
<div id="results-box" style="margin-bottom:1rem;"></div>
<div style="display:flex;gap:.5rem;flex-wrap:wrap;">
<button class="btn-primary" id="btn-save" onclick="saveAnnotations()">Save</button>
<button class="btn-primary" id="btn-save" onclick="saveAnnotations()">Save &amp; close</button>
<button class="btn-ghost" onclick="goStep(2)">← Edit</button>
</div>
<div id="save-status" style="font-size:0.82rem;margin-top:.5rem;"></div>
@@ -145,9 +160,11 @@
</style>
<script>
const PHOTO_URL = {{ photo.photo_url | tojson }};
const SAVE_URL = {{ url_for('sessions.annotate_photo', session_id=session.id, photo_id=photo.id) | tojson }};
const EXISTING = {{ (photo.annotations or {}) | tojson }};
const PHOTO_URL = {{ photo.photo_url | tojson }};
const SAVE_URL = {{ url_for('sessions.annotate_photo', session_id=session.id, photo_id=photo.id) | tojson }};
const SESSION_URL = {{ url_for('sessions.detail', session_id=session.id) | tojson }};
const SESSION_DIST_M = {{ (session.distance_m or 'null') }};
const EXISTING = {{ (photo.annotations or {}) | tojson }};
// ── State ──────────────────────────────────────────────────────────────────
let step = 0;
@@ -408,6 +425,8 @@ function renderResults() {
<span class="stat-val">${stats.shot_count}</span></div>
<div class="stat-row"><span class="stat-label">@ distance</span>
<span class="stat-val">${stats.shooting_distance_m.toFixed(0)} m</span></div>
<div class="stat-row"><span class="stat-label">Clean barrel</span>
<span class="stat-val">${document.getElementById('clean-barrel').checked ? 'Yes' : 'No'}</span></div>
`;
}
@@ -554,6 +573,7 @@ async function saveAnnotations() {
poa: toFrac(poa),
pois: pois.map(toFrac),
shooting_distance_m: stats.shooting_distance_m,
clean_barrel: document.getElementById('clean-barrel').checked,
stats: stats,
};
@@ -564,8 +584,7 @@ async function saveAnnotations() {
body: JSON.stringify(payload),
});
if (resp.ok) {
status.style.color = '#27ae60';
status.textContent = 'Saved!';
window.location.href = SESSION_URL;
} else {
throw new Error('Server error');
}
@@ -578,6 +597,12 @@ async function saveAnnotations() {
// ── Load existing annotations ──────────────────────────────────────────────
function loadExisting() {
// Always pre-fill shooting distance from session if available
if (SESSION_DIST_M) {
document.getElementById('shoot-dist').value = SESSION_DIST_M;
document.getElementById('shoot-unit').value = 'm';
}
if (!EXISTING || !EXISTING.ref) return;
const W = img.naturalWidth, H = img.naturalHeight;
function fromFrac(f) { return { x: f.x * W, y: f.y * H }; }
@@ -597,6 +622,9 @@ function loadExisting() {
document.getElementById('shoot-dist').value = EXISTING.shooting_distance_m.toFixed(0);
document.getElementById('shoot-unit').value = 'm';
}
if (EXISTING.clean_barrel) {
document.getElementById('clean-barrel').checked = true;
}
if (EXISTING.stats) {
stats = EXISTING.stats;
renderResults();

View File

@@ -8,7 +8,7 @@
{% if is_owner %}<a href="{{ url_for('sessions.index') }}">Sessions</a> &rsaquo; {% endif %}
{{ session.session_date.strftime('%d %b %Y') }}
{% if session.is_public %}
<span style="background:#e8f5e9;color:#27ae60;font-size:0.75rem;padding:.1rem .45rem;border-radius:3px;margin-left:.4rem;">Public</span>
<span style="background:#e8f5e9;color:#27ae60;font-size:0.75rem;padding:.1rem .45rem;border-radius:3px;margin-left:.4rem;">{{ _('Public') }}</span>
{% endif %}
</div>
<h1 style="margin:0;">{{ session.label }}</h1>
@@ -20,13 +20,13 @@
<div style="display:flex;gap:.75rem;">
<a href="{{ url_for('sessions.edit', session_id=session.id) }}"
style="background:#f0f4ff;color:#1a1a2e;padding:0.5rem 1.1rem;border-radius:4px;font-size:0.9rem;text-decoration:none;">
Edit
{{ _('Edit') }}
</a>
<form method="post" action="{{ url_for('sessions.delete', session_id=session.id) }}"
onsubmit="return confirm('Delete this session? This cannot be undone.');">
onsubmit="return confirm('{{ _('Delete this session? This cannot be undone.') | e }}');">
<button type="submit"
style="background:#fff0f0;color:#c0392b;border:1px solid #f5c6c6;border-radius:4px;padding:0.5rem 1.1rem;font-size:0.9rem;cursor:pointer;">
Delete
{{ _('Delete') }}
</button>
</form>
</div>
@@ -38,7 +38,7 @@
{% if session.location_name or session.distance_m %}
<div style="background:#f8f9fb;border-radius:6px;padding:1rem;">
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em;color:#888;margin-bottom:.4rem;">Location</div>
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em;color:#888;margin-bottom:.4rem;">{{ _('Location') }}</div>
{% if session.location_name %}<div style="font-weight:600;">{{ session.location_name }}</div>{% endif %}
{% if session.distance_m %}<div style="color:#555;font-size:0.9rem;">{{ session.distance_m }} m</div>{% endif %}
</div>
@@ -46,18 +46,18 @@
{% if session.weather_cond or session.weather_temp_c is not none or session.weather_wind_kph is not none %}
<div style="background:#f8f9fb;border-radius:6px;padding:1rem;">
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em;color:#888;margin-bottom:.4rem;">Weather</div>
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em;color:#888;margin-bottom:.4rem;">{{ _('Weather') }}</div>
{% if session.weather_cond %}<div style="font-weight:600;">{{ session.weather_cond.replace('_',' ').title() }}</div>{% endif %}
<div style="color:#555;font-size:0.9rem;">
{% if session.weather_temp_c is not none %}{{ session.weather_temp_c }}°C{% endif %}
{% if session.weather_wind_kph is not none %}&nbsp; {{ session.weather_wind_kph }} km/h wind{% endif %}
{% if session.weather_wind_kph is not none %}&nbsp; {{ session.weather_wind_kph }} {{ _('km/h wind') }}{% endif %}
</div>
</div>
{% endif %}
{% if session.rifle %}
<div style="background:#f8f9fb;border-radius:6px;padding:1rem;">
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em;color:#888;margin-bottom:.4rem;">Rifle / Handgun</div>
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em;color:#888;margin-bottom:.4rem;">{{ _('Rifle / Handgun') }}</div>
<div style="font-weight:600;">{{ session.rifle.name }}</div>
{% if session.rifle.caliber %}<div style="color:#555;font-size:0.9rem;">{{ session.rifle.caliber }}</div>{% endif %}
</div>
@@ -65,14 +65,14 @@
{% if session.scope %}
<div style="background:#f8f9fb;border-radius:6px;padding:1rem;">
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em;color:#888;margin-bottom:.4rem;">Scope</div>
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em;color:#888;margin-bottom:.4rem;">{{ _('Scope') }}</div>
<div style="font-weight:600;">{{ session.scope.name }}</div>
</div>
{% endif %}
{% if session.ammo_brand or session.ammo_weight_gr is not none %}
<div style="background:#f8f9fb;border-radius:6px;padding:1rem;">
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em;color:#888;margin-bottom:.4rem;">Ammo</div>
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em;color:#888;margin-bottom:.4rem;">{{ _('Ammunition') }}</div>
{% if session.ammo_brand %}<div style="font-weight:600;">{{ session.ammo_brand }}</div>{% endif %}
<div style="color:#555;font-size:0.9rem;">
{% if session.ammo_weight_gr is not none %}{{ session.ammo_weight_gr }} gr{% endif %}
@@ -81,16 +81,247 @@
</div>
{% endif %}
{% if session.shooting_position %}
<div style="background:#f8f9fb;border-radius:6px;padding:1rem;">
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em;color:#888;margin-bottom:.4rem;">{{ _('Position') }}</div>
<div style="font-weight:600;">{{ session.shooting_position.replace('_',' ').title() }}</div>
</div>
{% endif %}
</div>
{% if session.notes %}
<h2>Notes</h2>
<h2>{{ _('Notes') }}</h2>
<p style="color:#555;white-space:pre-wrap;">{{ session.notes }}</p>
{% endif %}
{# ---- PRS Stages ---- #}
{% if session.session_type == 'prs' %}
<h2>{{ _('PRS Stages') }}</h2>
{% set stages = session.prs_stages or [] %}
{# Stage table (read + edit combined) #}
<div style="overflow-x:auto;margin-bottom:1rem;">
<table id="prs-table" style="min-width:900px;font-size:0.88rem;">
<thead>
<tr>
<th style="width:36px;"></th>
<th>{{ _('Name') }}</th>
<th style="width:80px;">Dist. (m)</th>
<th style="width:75px;">{{ _('Time (s)') }}</th>
<th style="width:110px;">Position</th>
<th style="width:110px;">{{ _('Elevation Dope') }}</th>
<th style="width:110px;">{{ _('Windage Dope') }}</th>
<th style="width:80px;">{{ _('Hits/Poss.') }}</th>
<th>{{ _('Notes') }}</th>
{% if is_owner %}<th style="width:36px;"></th>{% endif %}
</tr>
</thead>
<tbody id="prs-tbody">
{% for st in stages %}
<tr data-idx="{{ loop.index0 }}">
<td style="text-align:center;font-weight:600;">{{ st.num or loop.index }}</td>
<td>{{ st.name or '' }}</td>
<td style="text-align:center;">{{ st.distance_m or '' }}</td>
<td style="text-align:center;">{{ st.time_s or '' }}</td>
<td>{{ st.position or '' }}</td>
<td style="text-align:center;">{{ st.dope_elevation or '' }}</td>
<td style="text-align:center;">{{ st.dope_windage or '' }}</td>
<td style="text-align:center;">
{% if st.hits is not none %}{{ st.hits }}{% endif %}{% if st.possible %}/{{ st.possible }}{% endif %}
</td>
<td>{{ st.notes or '' }}</td>
{% if is_owner %}<td></td>{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if is_owner %}
<div style="display:flex;gap:.75rem;flex-wrap:wrap;margin-bottom:1.5rem;">
<button onclick="enterEditMode()" id="btn-edit-stages"
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.5rem 1.1rem;font-size:0.9rem;cursor:pointer;">
{{ _('✏ Edit stages') }}
</button>
<a href="{{ url_for('sessions.dope_card', session_id=session.id) }}" target="_blank"
style="background:#27ae60;color:#fff;padding:.5rem 1.1rem;border-radius:4px;font-size:0.9rem;text-decoration:none;">
{{ _('📄 Generate dope card (PDF)') }}
</a>
</div>
{# Hidden form used by JS to save stages #}
<form method="post" action="{{ url_for('sessions.save_stages', session_id=session.id) }}" id="stages-form" style="display:none;">
<input type="hidden" name="stages_json" id="stages-json-input">
</form>
<script>
(function () {
var PRS_POS = [
["prone", "Couché"],
["standing", "Debout"],
["kneeling", "Agenouillé"],
["sitting", "Assis"],
["barricade", "Barricade"],
["rooftop", "Toit"],
["unknown", "Variable"],
];
var stages = {{ (session.prs_stages or []) | tojson }};
function posLabel(slug) {
var m = PRS_POS.find(function(p) { return p[0] === slug; });
return m ? m[1] : slug;
}
function posSelect(val) {
var s = document.createElement('select');
s.style.cssText = 'width:100%;font-size:0.85rem;padding:.2rem;border:1px solid #ccc;border-radius:3px;';
PRS_POS.forEach(function(p) {
var o = document.createElement('option');
o.value = p[0]; o.textContent = p[1];
if (p[0] === val) o.selected = true;
s.appendChild(o);
});
return s;
}
function inp(val, type, placeholder) {
var i = document.createElement('input');
i.type = type || 'text';
i.value = val == null ? '' : val;
if (placeholder) i.placeholder = placeholder;
i.style.cssText = 'width:100%;font-size:0.85rem;padding:.2rem .4rem;border:1px solid #ccc;border-radius:3px;box-sizing:border-box;';
return i;
}
function collectStages() {
var rows = document.querySelectorAll('#prs-tbody tr');
var result = [];
rows.forEach(function(tr, i) {
var cells = tr.querySelectorAll('td');
if (!cells.length) return;
function val(idx) {
var el = cells[idx];
if (!el) return '';
var input = el.querySelector('input,select,textarea');
return input ? (input.value || '') : (el.textContent.trim() || '');
}
result.push({
num: parseInt(val(0)) || i + 1,
name: val(1),
distance_m: parseInt(val(2)) || null,
time_s: parseInt(val(3)) || null,
position: val(4),
dope_elevation: val(5),
dope_windage: val(6),
hits: val(7).split('/')[0] !== '' ? parseInt(val(7).split('/')[0]) : null,
possible: val(7).split('/')[1] !== undefined ? (parseInt(val(7).split('/')[1]) || null) : null,
notes: val(8),
});
});
return result;
}
function addRow(st) {
var tbody = document.getElementById('prs-tbody');
var idx = tbody.rows.length;
st = st || { num: idx + 1 };
var tr = document.createElement('tr');
tr.dataset.idx = idx;
var hitsVal = '';
if (st.hits != null) hitsVal = st.hits;
if (st.possible != null) hitsVal = (hitsVal !== '' ? hitsVal : '') + '/' + st.possible;
[
inp(st.num, 'number'),
inp(st.name),
inp(st.distance_m, 'number'),
inp(st.time_s, 'number'),
posSelect(st.position || ''),
inp(st.dope_elevation, 'text', 'ex : 3.5 MOA'),
inp(st.dope_windage, 'text', 'ex : 0.5 MOA D'),
inp(hitsVal, 'text', 'x/y'),
inp(st.notes),
].forEach(function(el) {
var td = document.createElement('td');
td.appendChild(el);
tr.appendChild(td);
});
// Remove button
var tdDel = document.createElement('td');
tdDel.style.textAlign = 'center';
var btn = document.createElement('button');
btn.type = 'button';
btn.textContent = '✕';
btn.style.cssText = 'background:none;border:none;color:#c0392b;cursor:pointer;font-size:1rem;padding:0 .3rem;';
btn.onclick = function() { tr.remove(); renumber(); };
tdDel.appendChild(btn);
tr.appendChild(tdDel);
tbody.appendChild(tr);
}
function renumber() {
document.querySelectorAll('#prs-tbody tr').forEach(function(tr, i) {
var first = tr.querySelector('td:first-child input');
if (first && first.type === 'number') first.value = i + 1;
});
}
window.enterEditMode = function () {
// Replace read-only rows with editable rows
var tbody = document.getElementById('prs-tbody');
tbody.innerHTML = '';
stages.forEach(function(st) { addRow(st); });
if (!stages.length) addRow(null);
document.getElementById('btn-edit-stages').style.display = 'none';
var STR_ADD_STAGE = '{{ _("+ Add stage") | e }}';
var STR_SAVE = '{{ _("💾 Save") | e }}';
var STR_CANCEL = '{{ _("Cancel") | e }}';
// Add / Save / Cancel buttons
var bar = document.createElement('div');
bar.id = 'edit-bar';
bar.style.cssText = 'display:flex;gap:.6rem;flex-wrap:wrap;margin:.75rem 0;';
bar.innerHTML =
'<button type="button" onclick="addRow(null);renumber();" ' +
'style="background:#f0f4ff;color:#1a1a2e;border:1px solid #c8d4f0;border-radius:4px;padding:.4rem .9rem;font-size:0.88rem;cursor:pointer;">' +
STR_ADD_STAGE + '</button>' +
'<button type="button" onclick="saveStages()" ' +
'style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.4rem .9rem;font-size:0.88rem;cursor:pointer;">' +
STR_SAVE + '</button>' +
'<button type="button" onclick="cancelEdit()" ' +
'style="background:none;color:#666;border:none;font-size:0.88rem;cursor:pointer;padding:.4rem .5rem;">' + STR_CANCEL + '</button>';
document.getElementById('prs-table').after(bar);
};
window.saveStages = function () {
var data = collectStages();
document.getElementById('stages-json-input').value = JSON.stringify(data);
document.getElementById('stages-form').submit();
};
window.cancelEdit = function () {
location.reload();
};
window.addRow = addRow;
window.renumber = renumber;
})();
</script>
{% endif %}
{% endif %}{# end prs #}
{# ---- Photos ---- #}
{% if session.photos or is_owner %}
<h2>Photos</h2>
<h2>{{ _('Photos') }}</h2>
{% if session.photos %}
<div style="display:flex;flex-wrap:wrap;gap:1rem;margin-bottom:1.5rem;">
{% for photo in session.photos %}
@@ -105,7 +336,7 @@
{% if is_owner %}
<form method="post"
action="{{ url_for('sessions.delete_photo', session_id=session.id, photo_id=photo.id) }}"
onsubmit="return confirm('Delete this photo?');"
onsubmit="return confirm('{{ _('Delete this photo?') | e }}');"
style="position:absolute;top:4px;right:4px;">
<button type="submit"
style="background:rgba(0,0,0,.5);color:#fff;border:none;border-radius:3px;padding:.2rem .45rem;font-size:0.8rem;cursor:pointer;line-height:1.2;">
@@ -116,8 +347,32 @@
</div>
{% if photo.annotations and photo.annotations.stats %}
{% set s = photo.annotations.stats %}
<div style="font-size:0.78rem;background:#f0f4ff;color:#1a1a2e;padding:.2rem .45rem;border-radius:3px;margin-top:.3rem;font-weight:600;">
{{ s.shot_count }} shots &middot; {{ '%.2f'|format(s.group_size_moa) }} MOA ES
{% set a = photo.annotations %}
<div style="margin-top:.5rem;background:#f8f9fb;border:1px solid #e0e0e0;border-radius:6px;padding:.6rem .75rem;font-size:0.8rem;min-width:180px;">
<div style="font-weight:700;color:#1a1a2e;margin-bottom:.35rem;font-size:0.82rem;">
{{ s.shot_count }} shot{{ 's' if s.shot_count != 1 }}
&middot; {{ s.shooting_distance_m | int }} m
{% if a.clean_barrel %}<span style="background:#e8f5e9;color:#27ae60;border-radius:3px;padding:.05rem .3rem;margin-left:.3rem;">{{ _('clean barrel') }}</span>{% endif %}
</div>
<table style="width:100%;border-collapse:collapse;margin:0;">
<tr>
<td style="color:#666;padding:.15rem 0;border:none;">{{ _('Group ES') }}</td>
<td style="font-weight:600;text-align:right;padding:.15rem 0;border:none;">{{ '%.2f'|format(s.group_size_moa) }} MOA</td>
<td style="color:#888;text-align:right;padding:.15rem 0 .15rem .4rem;border:none;">{{ '%.1f'|format(s.group_size_mm) }} mm</td>
</tr>
<tr>
<td style="color:#666;padding:.15rem 0;border:none;">{{ _('Mean Radius') }}</td>
<td style="font-weight:600;text-align:right;padding:.15rem 0;border:none;">{{ '%.2f'|format(s.mean_radius_moa) }} MOA</td>
<td style="color:#888;text-align:right;padding:.15rem 0 .15rem .4rem;border:none;">{{ '%.1f'|format(s.mean_radius_mm) }} mm</td>
</tr>
<tr>
<td style="color:#666;padding:.15rem 0;border:none;">{{ _('Centre') }}</td>
<td colspan="2" style="text-align:right;padding:.15rem 0;border:none;">
{{ '%.2f'|format(s.center_dist_moa) }} MOA
<span style="color:#888;">({{ '%.1f'|format(s.center_x_mm | abs) }} mm {{ 'R' if s.center_x_mm > 0 else 'L' if s.center_x_mm < 0 else '' }}, {{ '%.1f'|format(s.center_y_mm | abs) }} mm {{ _('low') if s.center_y_mm > 0 else _('high') if s.center_y_mm < 0 else '' }})</span>
</td>
</tr>
</table>
</div>
{% endif %}
{% if photo.caption %}
@@ -145,7 +400,7 @@
background:#1a1a2e;color:#fff;border:1px solid #1a1a2e;
{% endif %}">
{% if photo.annotations and photo.annotations.stats %}&#10003;{% else %}&#9654;{% endif %}
Measure group
{{ _('Measure group') }}
</a>
{% endif %}
</div>
@@ -159,17 +414,17 @@
enctype="multipart/form-data"
style="display:flex;flex-wrap:wrap;gap:.75rem;align-items:flex-end;margin-bottom:2rem;">
<div>
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.25rem;">Add photo</label>
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.25rem;">{{ _('Add photo') }}</label>
<input type="file" name="photo" accept="image/*" required style="font-size:0.9rem;">
</div>
<div>
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.25rem;">Caption (optional)</label>
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.25rem;">{{ _('Caption (optional)') }}</label>
<input type="text" name="caption" placeholder="e.g. 300 m target"
style="padding:.45rem .7rem;border:1px solid #ccc;border-radius:4px;font-size:0.9rem;">
</div>
<button type="submit"
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.5rem 1.1rem;font-size:0.9rem;cursor:pointer;">
Upload
{{ _('Upload') }}
</button>
</form>
{% endif %}
@@ -178,25 +433,161 @@
{# ---- Analyses ---- #}
<h2>Analyses{% if analyses %} ({{ analyses|length }}){% endif %}</h2>
{% if analyses %}
<table style="margin-bottom:1.5rem;">
<thead>
<tr><th>Title</th><th>Date</th><th>Shots</th><th>Groups</th><th>Mean speed</th></tr>
</thead>
<tbody>
{% for a in analyses %}
<tr style="cursor:pointer;" onclick="location.href='{{ url_for('analyses.detail', analysis_id=a.id) }}'">
<td><a href="{{ url_for('analyses.detail', analysis_id=a.id) }}" style="color:inherit;text-decoration:none;">{{ a.title }}</a></td>
<td style="color:#666;font-size:0.88rem;">{{ a.created_at.strftime('%d %b %Y') }}</td>
<td>{{ a.shot_count }}</td>
<td>{{ a.group_count }}</td>
<td>{{ "%.2f"|format(a.overall_stats.mean_speed) }} m/s</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if analyses_display %}
{% for a, groups_display, overview_chart in analyses_display %}
<details open style="border:1px solid #e0e0e0;border-radius:8px;margin-bottom:1.25rem;overflow:hidden;">
<summary style="display:flex;align-items:center;gap:.75rem;padding:.85rem 1.25rem;
background:#f8f9fb;cursor:pointer;list-style:none;flex-wrap:wrap;">
<span style="font-weight:700;font-size:1rem;color:#1a1a2e;flex:1;">{{ a.title }}</span>
<span style="font-size:0.82rem;color:#666;">{{ a.shot_count }} {{ _('shots') }} &middot; {{ a.group_count }} {{ _('group') if a.group_count == 1 else _('groups') }}</span>
<span style="font-size:0.82rem;color:#888;">{{ "%.2f"|format(a.overall_stats.mean_speed) }} {{ _('m/s mean') }}</span>
<span style="font-size:0.78rem;color:#aaa;">{{ a.created_at.strftime('%d %b %Y') }}</span>
</summary>
<div style="padding:1.25rem 1.5rem;">
{# --- Owner action bar: rename / standalone link / PDF / delete --- #}
{% if is_owner %}
<div style="display:flex;flex-wrap:wrap;gap:.6rem;align-items:center;margin-bottom:1.25rem;">
{# Rename inline form #}
<details style="display:inline;">
<summary style="display:inline-block;padding:.3rem .75rem;background:#f0f4ff;color:#1a1a2e;
border:1px solid #c8d4f0;border-radius:4px;font-size:0.82rem;cursor:pointer;list-style:none;">
{{ _('✏ Rename') }}
</summary>
<form method="post" action="{{ url_for('analyses.rename', analysis_id=a.id) }}"
style="display:flex;gap:.5rem;align-items:center;margin-top:.5rem;">
<input type="text" name="title" value="{{ a.title }}" required
style="padding:.4rem .65rem;border:1px solid #ccc;border-radius:4px;font-size:0.9rem;min-width:220px;">
<button type="submit"
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.4rem .9rem;font-size:0.85rem;cursor:pointer;">
{{ _('Save') }}
</button>
</form>
</details>
<a href="{{ url_for('analyses.detail', analysis_id=a.id) }}"
style="padding:.3rem .75rem;background:#f0f4ff;color:#1a1a2e;border:1px solid #c8d4f0;
border-radius:4px;font-size:0.82rem;text-decoration:none;">
{{ _('Full view') }}
</a>
{% if a.pdf_path %}
<a href="{{ url_for('analyses.download_pdf', analysis_id=a.id) }}"
style="padding:.3rem .75rem;background:#f0f4ff;color:#1a1a2e;border:1px solid #c8d4f0;
border-radius:4px;font-size:0.82rem;text-decoration:none;">
&#8615; PDF
</a>
{% endif %}
<form method="post" action="{{ url_for('analyses.delete', analysis_id=a.id) }}"
onsubmit="return confirm('{{ _('Delete this analysis? This cannot be undone.') | e }}');"
style="display:inline;">
<button type="submit"
style="background:#fff0f0;color:#c0392b;border:1px solid #f5c6c6;border-radius:4px;
padding:.3rem .75rem;font-size:0.82rem;cursor:pointer;">
{{ _('Delete') }}
</button>
</form>
</div>
{% endif %}
{# --- Overview chart --- #}
{% if overview_chart %}
<img src="data:image/png;base64,{{ overview_chart }}" class="chart-img" alt="Overview chart"
style="margin-bottom:1.25rem;">
{% endif %}
{# --- Per-group cards --- #}
{% if groups_display %}
{% for gs, chart in groups_display %}
<div class="group-section" style="margin-bottom:1rem;">
<div class="group-meta">
<strong>{{ _('Group %(n)s', n=loop.index) }}</strong>
&nbsp;&middot;&nbsp; {{ gs.count }} {{ _('shots') }}
&nbsp;&middot;&nbsp; {{ _('mean') }} {{ "%.2f"|format(gs.mean_speed) }} m/s
{% if gs.std_speed is not none %}&nbsp;&middot;&nbsp; {{ _('SD') }} {{ "%.2f"|format(gs.std_speed) }}{% endif %}
&nbsp;&middot;&nbsp; {{ _('ES') }} {{ "%.2f"|format(gs.max_speed - gs.min_speed) }}
</div>
<img src="data:image/png;base64,{{ chart }}" class="chart-img" alt="Group {{ loop.index }} chart">
{% if gs.note %}
<div style="margin-top:.75rem;padding:.5rem .75rem;background:#fffbea;border-left:3px solid #f0c040;
border-radius:0 4px 4px 0;font-size:0.88rem;color:#555;white-space:pre-wrap;">{{ gs.note }}</div>
{% endif %}
{% if is_owner %}
<details style="margin-top:.75rem;">
<summary style="font-size:0.82rem;color:#888;cursor:pointer;list-style:none;display:inline;">
&#9998; {{ _('Note') }}
</summary>
<form method="post"
action="{{ url_for('analyses.save_group_note', analysis_id=a.id, group_index=loop.index0) }}"
style="margin-top:.4rem;display:flex;flex-direction:column;gap:.4rem;">
<textarea name="note" rows="2"
style="padding:.45rem .65rem;border:1px solid #ccc;border-radius:4px;font-size:0.88rem;resize:vertical;width:100%;max-width:520px;">{{ gs.note or '' }}</textarea>
<div>
<button type="submit"
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.35rem .85rem;font-size:0.82rem;cursor:pointer;">
{{ _('Save note') }}
</button>
</div>
</form>
</details>
{% endif %}
</div>
{% endfor %}
{% elif groups_display is none %}
<p style="color:#e74c3c;font-size:0.9rem;">{{ _('CSV file missing — cannot display charts.') }}</p>
{% endif %}
{# --- Re-group panel (owner only) --- #}
{% if is_owner %}
<details style="margin-top:1rem;border-top:1px solid #e8e8e8;padding-top:.9rem;">
<summary style="font-size:0.85rem;color:#888;cursor:pointer;list-style:none;">
&#9881; {{ _('Re-group settings') }}
</summary>
<form method="post" action="{{ url_for('analyses.regroup', analysis_id=a.id) }}"
style="margin-top:.75rem;display:flex;flex-direction:column;gap:.75rem;max-width:480px;">
<div>
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">
{{ _('Outlier factor:') }}
<span id="factor_val_{{ a.id }}">{{ a.grouping_outlier_factor or 5 }}</span>
</label>
<input type="range" name="outlier_factor" min="1" max="20" step="0.5"
value="{{ a.grouping_outlier_factor or 5 }}"
style="width:100%;"
oninput="document.getElementById('factor_val_{{ a.id }}').textContent=this.value">
<div style="display:flex;justify-content:space-between;font-size:0.75rem;color:#aaa;">
<span>{{ _('1 (fine)') }}</span><span>{{ _('20 (coarse)') }}</span>
</div>
</div>
<div>
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">
{{ _('Manual split indices (JSON array, e.g. [5, 12])') }}
</label>
<input type="text" name="manual_splits"
value="{{ (a.grouping_manual_splits | tojson) if a.grouping_manual_splits else '' }}"
placeholder="e.g. [5, 12]"
style="width:100%;padding:.45rem .7rem;border:1px solid #ccc;border-radius:4px;font-size:0.9rem;">
<div style="font-size:0.75rem;color:#aaa;margin-top:.2rem;">{{ _('Shot positions (0-based) where a new group should always begin.') }}</div>
</div>
<div>
<button type="submit"
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.45rem 1.1rem;font-size:0.88rem;cursor:pointer;">
{{ _('Apply') }}
</button>
</div>
</form>
</details>
{% endif %}
</div>
</details>
{% endfor %}
{% else %}
<p style="color:#888;margin-bottom:1.5rem;">No analyses yet.</p>
<p style="color:#888;margin-bottom:1.5rem;">{{ _('No analyses yet.') }}</p>
{% endif %}
{% if is_owner %}
@@ -205,12 +596,12 @@
enctype="multipart/form-data"
style="display:flex;flex-wrap:wrap;gap:.75rem;align-items:flex-end;margin-bottom:2rem;">
<div>
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.25rem;">Upload chronograph CSV</label>
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.25rem;">{{ _('Upload chronograph CSV') }}</label>
<input type="file" name="csv_file" accept=".csv,text/csv" required style="font-size:0.9rem;">
</div>
<button type="submit"
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.5rem 1.1rem;font-size:0.9rem;cursor:pointer;">
Analyse &amp; link
{{ _('Analyse & link') }}
</button>
</form>
{% endif %}

View File

@@ -1,44 +1,93 @@
{% extends "base.html" %}
{% set editing = session is not none %}
{% block title %}{{ 'Edit session' if editing else 'New session' }} — The Shooter's Network{% endblock %}
{# Effective type: prefill form > existing session > URL param #}
{% set eff_type = (prefill.session_type if prefill else None) or (session.session_type if session else None) or selected_type or '' %}
{# Find display name for this type #}
{% set type_name = '' %}
{% for slug, name, _ in (session_types or []) %}{% if slug == eff_type %}{% set type_name = name %}{% endif %}{% endfor %}
{% block title %}{{ _('Edit session') if editing else _('New session') }} — The Shooter's Network{% endblock %}
{% block content %}
<h1>{{ 'Edit session' if editing else 'Log a session' }}</h1>
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:.4rem;">
{% if not editing %}
<a href="{{ url_for('sessions.new') }}" style="font-size:0.85rem;color:#888;text-decoration:none;">{{ _('← Change type') }}</a>
{% endif %}
</div>
<h1 style="margin-bottom:1.5rem;">
{{ _('Edit session') if editing else _('New session') }}
{% if type_name %}
<span style="font-size:0.95rem;font-weight:400;color:#666;margin-left:.6rem;">— {{ type_name }}</span>
{% endif %}
</h1>
{% set f = prefill or session %}
<form method="post"
action="{{ url_for('sessions.edit', session_id=session.id) if editing else url_for('sessions.new') }}"
style="max-width:580px;">
style="max-width:600px;">
<h2>Basic info</h2>
<input type="hidden" name="session_type" id="session_type_hidden" value="{{ eff_type }}">
{# In edit mode: allow changing type via a small selector #}
{% if editing %}
<div style="margin-bottom:1.5rem;padding:.75rem 1rem;background:#f8f9fb;border-radius:6px;display:flex;align-items:center;gap:1rem;">
<label style="font-size:.85rem;font-weight:600;color:#444;white-space:nowrap;">{{ _('Session type:') }}</label>
<select id="type_sel" onchange="document.getElementById('session_type_hidden').value=this.value;applyType(this.value);"
style="padding:.4rem .7rem;border:1px solid #ccc;border-radius:4px;font-size:0.9rem;background:#fff;">
{% for slug, name, _ in (session_types or []) %}
<option value="{{ slug }}" {% if slug == eff_type %}selected{% endif %}>{{ name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
{# ── Section: basic information ── #}
<h2>{{ _('Basic information') }}</h2>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem;">
<div>
<label class="fl">Date *</label>
<label class="fl">{{ _('Date *') }}</label>
<input type="date" name="session_date" required
value="{{ (f.session_date.isoformat() if f.session_date else '') if f else (today or '') }}"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div>
<label class="fl">Distance (m)</label>
<input type="number" name="distance_m" min="1" max="5000"
value="{{ f.distance_m if f and f.distance_m else '' }}"
{# Distance: shown for long_range and pistol_25m (hidden for prs — per stage) #}
<div id="field_distance_wrap">
<label class="fl">{{ _('Distance (m)') }}</label>
<input type="number" name="distance_m" min="1" max="5000" id="field_distance"
value="{{ f.distance_m if f and f.distance_m else (prefill_distance or '') }}"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
</div>
<div style="margin-bottom:1rem;">
<label class="fl">Location</label>
<label class="fl">{{ _('Location') }}</label>
<input type="text" name="location_name" value="{{ f.location_name if f else '' }}"
placeholder="e.g. Range name, city"
placeholder="ex : Nom du stand, commune"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<h2>Weather</h2>
{# ── Section: shooting position (long_range and pistol_25m) ── #}
<div id="section_position">
<div style="margin-bottom:1rem;">
<label class="fl">{{ _('Shooting position') }}</label>
<select name="shooting_position" id="field_position"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.93rem;background:#fff;">
{# Options injected by JS based on type #}
</select>
</div>
</div>
{# ── Section: weather ── #}
<h2>{{ _('Weather') }}</h2>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:1rem;margin-bottom:1rem;">
<div>
<label class="fl">Condition</label>
<label class="fl">{{ _('Conditions') }}</label>
<select name="weather_cond"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.93rem;background:#fff;">
{% for val, label in weather_conditions %}
@@ -47,27 +96,27 @@
</select>
</div>
<div>
<label class="fl">Temp (°C)</label>
<label class="fl">{{ _('Temp. (°C)') }}</label>
<input type="number" name="weather_temp_c" step="0.1"
value="{{ f.weather_temp_c if f and f.weather_temp_c is not none else '' }}"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div>
<label class="fl">Wind (km/h)</label>
<label class="fl">{{ _('Wind (km/h)') }}</label>
<input type="number" name="weather_wind_kph" step="0.1" min="0"
value="{{ f.weather_wind_kph if f and f.weather_wind_kph is not none else '' }}"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
</div>
<h2>Equipment &amp; Ammo</h2>
{# ── Section: equipment & ammunition ── #}
<h2>{{ _('Equipment & Ammunition') }}</h2>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem;">
<div>
<label class="fl">Rifle / Handgun</label>
<label class="fl">{{ _('Rifle / Handgun') }}</label>
<select name="rifle_id"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.93rem;background:#fff;">
<option value="">— none —</option>
<option value="">{{ _('— none —') }}</option>
{% for r in rifles %}
<option value="{{ r.id }}" {% if f and f.rifle_id == r.id %}selected{% endif %}>
{{ r.name }}{% if r.caliber %} ({{ r.caliber }}){% endif %}
@@ -76,15 +125,15 @@
</select>
{% if not rifles %}
<div style="font-size:0.78rem;color:#aaa;margin-top:.25rem;">
<a href="{{ url_for('equipment.new') }}">Add a rifle first</a>
<a href="{{ url_for('equipment.new') }}">{{ _('Add a rifle first') }}</a>
</div>
{% endif %}
</div>
<div>
<label class="fl">Scope</label>
<label class="fl">{{ _('Scope') }}</label>
<select name="scope_id"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.93rem;background:#fff;">
<option value="">— none —</option>
<option value="">{{ _('— none —') }}</option>
{% for sc in scopes %}
<option value="{{ sc.id }}" {% if f and f.scope_id == sc.id %}selected{% endif %}>{{ sc.name }}</option>
{% endfor %}
@@ -94,50 +143,97 @@
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:1rem;margin-bottom:1rem;">
<div>
<label class="fl">Ammo brand</label>
<label class="fl">{{ _('Ammo brand') }}</label>
<input type="text" name="ammo_brand" value="{{ f.ammo_brand if f else '' }}"
placeholder="e.g. Lapua, Federal"
placeholder="ex : Lapua, RWS"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div>
<label class="fl">Bullet weight (gr)</label>
<label class="fl">{{ _('Bullet weight (gr)') }}</label>
<input type="number" name="ammo_weight_gr" step="0.1" min="0"
value="{{ f.ammo_weight_gr if f and f.ammo_weight_gr is not none else '' }}"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
<div>
<label class="fl">Lot number</label>
<label class="fl">{{ _('Lot number') }}</label>
<input type="text" name="ammo_lot" value="{{ f.ammo_lot if f else '' }}"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
</div>
</div>
<h2>Notes &amp; Visibility</h2>
{# ── Section: notes & visibility ── #}
<h2>{{ _('Notes & Visibility') }}</h2>
<div style="margin-bottom:1rem;">
<label class="fl">Notes</label>
<label class="fl">{{ _('Notes') }}</label>
<textarea name="notes" rows="4"
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;resize:vertical;">{{ f.notes if f else '' }}</textarea>
</div>
<div style="margin-bottom:1.5rem;">
<label style="display:flex;align-items:center;gap:0.6rem;cursor:pointer;font-size:0.95rem;">
<input type="checkbox" name="is_public" value="1"
{% if f and f.is_public %}checked{% endif %}
style="width:16px;height:16px;">
Make this session public (visible in the community feed)
{{ _('Make this session public (visible in the community feed)') }}
</label>
</div>
<div style="display:flex;gap:1rem;align-items:center;">
<button type="submit"
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:0.6rem 1.5rem;font-size:0.95rem;cursor:pointer;">
{{ 'Save changes' if editing else 'Log session' }}
{{ _('Save') if editing else _('Create session') }}
</button>
<a href="{{ url_for('sessions.detail', session_id=session.id) if editing else url_for('sessions.index') }}"
style="font-size:0.9rem;color:#666;">Cancel</a>
style="font-size:0.9rem;color:#666;">{{ _('Cancel') }}</a>
</div>
</form>
<style>.fl { display:block; font-size:.88rem; font-weight:600; color:#444; margin-bottom:.3rem; }</style>
<script>
(function () {
var LR_POS = {{ (long_range_positions | tojson) if long_range_positions else '[]' }};
var P25_POS = {{ (pistol_25m_positions | tojson) if pistol_25m_positions else '[]' }};
function buildOptions(sel, opts, currentVal) {
sel.innerHTML = '';
opts.forEach(function(o) {
var opt = document.createElement('option');
opt.value = o[0]; opt.textContent = o[1];
if (o[0] === currentVal) opt.selected = true;
sel.appendChild(opt);
});
}
var currentPosition = {{ ((f.shooting_position if f else None) or '') | tojson }};
function applyType(t) {
var distWrap = document.getElementById('field_distance_wrap');
var posSection = document.getElementById('section_position');
var posSelect = document.getElementById('field_position');
if (t === 'prs') {
if (distWrap) distWrap.style.display = 'none';
if (posSection) posSection.style.display = 'none';
} else if (t === 'pistol_25m') {
if (distWrap) distWrap.style.display = '';
if (posSection) posSection.style.display = '';
buildOptions(posSelect, P25_POS, currentPosition);
var distField = document.getElementById('field_distance');
if (distField && !distField.value) distField.value = '25';
} else {
// long_range
if (distWrap) distWrap.style.display = '';
if (posSection) posSection.style.display = '';
buildOptions(posSelect, LR_POS, currentPosition);
}
}
document.addEventListener('DOMContentLoaded', function () {
applyType({{ eff_type | tojson }});
});
// Expose for the type selector in edit mode
window.applyType = applyType;
})();
</script>
{% endblock %}

View File

@@ -1,11 +1,11 @@
{% extends "base.html" %}
{% block title %}Sessions — The Shooter's Network{% endblock %}
{% block title %}{{ _('Sessions') }} — The Shooter's Network{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:1rem;margin-bottom:1.5rem;">
<h1 style="margin:0;">My Sessions</h1>
<h1 style="margin:0;">{{ _('My Sessions') }}</h1>
<a href="{{ url_for('sessions.new') }}"
style="background:#1a1a2e;color:#fff;padding:0.5rem 1.2rem;border-radius:4px;font-size:0.92rem;text-decoration:none;">
+ New session
{{ _('+ New session') }}
</a>
</div>
@@ -13,9 +13,10 @@
<table>
<thead>
<tr>
<th>Session</th>
<th>Location</th>
<th>Visibility</th>
<th>{{ _('Session') }}</th>
<th>{{ _('Type') }}</th>
<th>{{ _('Location') }}</th>
<th>{{ _('Visibility') }}</th>
<th></th>
</tr>
</thead>
@@ -23,15 +24,24 @@
{% for s in sessions %}
<tr>
<td><a href="{{ url_for('sessions.detail', session_id=s.id) }}">{{ s.session_date.strftime('%d %b %Y') }}</a></td>
<td style="font-size:0.82rem;">
{% if s.session_type == 'long_range' %}
<span style="background:#f0f4ff;color:#1a1a2e;padding:.15rem .5rem;border-radius:3px;">{{ _('Long Range') }}</span>
{% elif s.session_type == 'prs' %}
<span style="background:#fff3e0;color:#e65100;padding:.15rem .5rem;border-radius:3px;">PRS</span>
{% elif s.session_type == 'pistol_25m' %}
<span style="background:#f3e5f5;color:#6a1b9a;padding:.15rem .5rem;border-radius:3px;">{{ _('25m Pistol') }}</span>
{% else %}—{% endif %}
</td>
<td style="color:#666;font-size:0.88rem;">{{ s.location_name or '—' }}</td>
<td style="font-size:0.85rem;color:{% if s.is_public %}#27ae60{% else %}#aaa{% endif %};">
{{ 'Public' if s.is_public else 'Private' }}
{{ _('Public') if s.is_public else _('Private') }}
</td>
<td style="white-space:nowrap;">
<a href="{{ url_for('sessions.edit', session_id=s.id) }}" style="font-size:0.85rem;margin-right:.75rem;">Edit</a>
<a href="{{ url_for('sessions.edit', session_id=s.id) }}" style="font-size:0.85rem;margin-right:.75rem;">{{ _('Edit') }}</a>
<form method="post" action="{{ url_for('sessions.delete', session_id=s.id) }}" style="display:inline;"
onsubmit="return confirm('Delete this session?');">
<button type="submit" class="btn-link" style="font-size:0.85rem;color:#e74c3c;">Delete</button>
onsubmit="return confirm('{{ _('Delete this session?') | e }}');">
<button type="submit" class="btn-link" style="font-size:0.85rem;color:#e74c3c;">{{ _('Delete') }}</button>
</form>
</td>
</tr>
@@ -41,8 +51,8 @@
{% else %}
<div style="text-align:center;padding:3rem 0;color:#888;">
<div style="font-size:3rem;margin-bottom:1rem;">🎯</div>
<p style="margin-bottom:1rem;">No sessions recorded yet.</p>
<a href="{{ url_for('sessions.new') }}">Log your first session</a>
<p style="margin-bottom:1rem;">{{ _('No sessions recorded yet.') }}</p>
<a href="{{ url_for('sessions.new') }}">{{ _('Log your first session') }}</a>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}{{ _('New session') }} — The Shooter's Network{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:1rem;margin-bottom:.5rem;">
<h1 style="margin:0;">{{ _('New session') }}</h1>
<a href="{{ url_for('sessions.index') }}" style="font-size:0.9rem;color:#666;">{{ _('Cancel') }}</a>
</div>
<p style="color:#666;margin-bottom:2rem;font-size:0.95rem;">{{ _('Choose the session type.') }}</p>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:1.5rem;">
{% for slug, name, desc in session_types %}
<a href="{{ url_for('sessions.new', type=slug) }}"
style="display:flex;flex-direction:column;padding:1.5rem 1.75rem;border:2px solid #e0e0e0;border-radius:10px;
text-decoration:none;color:inherit;background:#fff;
transition:border-color .15s,box-shadow .15s,transform .1s;"
onmouseover="this.style.borderColor='#1a1a2e';this.style.boxShadow='0 4px 14px rgba(0,0,0,.1)';this.style.transform='translateY(-2px)'"
onmouseout="this.style.borderColor='#e0e0e0';this.style.boxShadow='none';this.style.transform='none'">
{% if slug == 'long_range' %}
<div style="font-size:2rem;margin-bottom:.6rem;">🎯</div>
{% elif slug == 'prs' %}
<div style="font-size:2rem;margin-bottom:.6rem;">🏔️</div>
{% else %}
<div style="font-size:2rem;margin-bottom:.6rem;">🔫</div>
{% endif %}
<div style="font-weight:700;font-size:1.1rem;color:#1a1a2e;margin-bottom:.4rem;">{{ _(name) }}</div>
<div style="font-size:0.85rem;color:#777;line-height:1.5;flex:1;">{{ _(desc) }}</div>
{% if slug == 'prs' %}
<div style="margin-top:1rem;font-size:0.78rem;color:#1f77b4;font-weight:600;">
{{ _('Stage management, PDF dope card ↗') }}
</div>
{% endif %}
</a>
{% endfor %}
</div>
{% endblock %}

View File

@@ -1,13 +1,13 @@
{% extends "base.html" %}
{% block content %}
<h1>New Analysis</h1>
<h1>{{ _('New Analysis') }}</h1>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
<p style="margin-bottom:1.5rem; color:#555;">
Upload a CSV file to analyse shot groups. The file must contain the following columns:
{{ _('Upload a CSV file to analyse shot groups. The file must contain the following columns:') }}
<strong>index</strong>, <strong>speed</strong>, <strong>standard deviation</strong>,
<strong>energy</strong>, <strong>power factor</strong>, <strong>time of the day</strong>.
</p>
@@ -24,7 +24,7 @@
type="submit"
style="background:#1f77b4;color:#fff;border:none;border-radius:4px;padding:0.55rem 1.4rem;font-size:0.95rem;cursor:pointer;"
>
Analyze
{{ _('Analyze') }}
</button>
</form>
{% endblock %}

Binary file not shown.

View File

@@ -0,0 +1,653 @@
msgid ""
msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: de\n"
msgid "New Analysis"
msgstr "Neue Analyse"
msgid "Equipment"
msgstr "Ausrüstung"
msgid "Sessions"
msgstr "Sitzungen"
msgid "Dashboard"
msgstr "Dashboard"
msgid "Profile"
msgstr "Profil"
msgid "Logout"
msgstr "Abmelden"
msgid "Login"
msgstr "Anmelden"
msgid "Join free"
msgstr "Kostenlos registrieren"
msgid "Save"
msgstr "Speichern"
msgid "Cancel"
msgstr "Abbrechen"
msgid "Edit"
msgstr "Bearbeiten"
msgid "Delete"
msgstr "Löschen"
msgid "Apply"
msgstr "Anwenden"
msgid "Upload"
msgstr "Hochladen"
msgid "Notes"
msgstr "Notizen"
msgid "Public"
msgstr "Öffentlich"
msgid "Private"
msgstr "Privat"
msgid "Date"
msgstr "Datum"
msgid "Location"
msgstr "Ort"
msgid "Name"
msgstr "Name"
msgid "Category"
msgstr "Kategorie"
msgid "Caliber"
msgstr "Kaliber"
msgid "Distance"
msgstr "Distanz"
msgid "Position"
msgstr "Position"
msgid "Log a Session"
msgstr "Sitzung erfassen"
msgid "Get started — free"
msgstr "Kostenlos starten"
msgid "Try without account"
msgstr "Ohne Konto ausprobieren"
msgid "Ballistic Analysis"
msgstr "Ballistische Analyse"
msgid "Session Tracking"
msgstr "Sitzungsverfolgung"
msgid "Community Feed"
msgstr "Community-Feed"
msgid "Upload CSV files from your chronograph and get instant shot-group statistics, velocity charts, and PDF reports."
msgstr "Laden Sie CSV-Dateien von Ihrem Chronograph hoch und erhalten Sie sofort Schussgruppenstatistiken, Geschwindigkeitsdiagramme und PDF-Berichte."
msgid "Log every range visit with location, weather, rifle, ammo, and distance. All your data in one place."
msgstr "Erfassen Sie jeden Schießstandbesuch mit Ort, Wetter, Gewehr, Munition und Distanz. Alle Ihre Daten an einem Ort."
msgid "Share your public sessions and see what other shooters are achieving on the range."
msgstr "Teilen Sie Ihre öffentlichen Sitzungen und sehen Sie, was andere Schützen auf dem Schießstand erreichen."
msgid "Latest sessions"
msgstr "Neueste Sitzungen"
msgid "No public sessions yet. Be the first to share one!"
msgstr "Noch keine öffentlichen Sitzungen. Seien Sie der Erste, der eine teilt!"
msgid "Analyze"
msgstr "Analysieren"
msgid "Upload a CSV file to analyse shot groups. The file must contain the following columns:"
msgstr "Laden Sie eine CSV-Datei hoch, um Schussgruppen zu analysieren. Die Datei muss folgende Spalten enthalten:"
msgid "Analysis Results"
msgstr "Analyseergebnisse"
msgid "← Upload another file"
msgstr "← Weitere Datei hochladen"
msgid "View saved report →"
msgstr "Gespeicherten Bericht ansehen →"
msgid "⬙ Download PDF report"
msgstr "⬙ PDF-Bericht herunterladen"
msgid "Overall Statistics"
msgstr "Gesamtstatistiken"
msgid "Metric"
msgstr "Messgröße"
msgid "Value"
msgstr "Wert"
msgid "Total shots"
msgstr "Schüsse gesamt"
msgid "Min speed"
msgstr "Min. Geschwindigkeit"
msgid "Max speed"
msgstr "Max. Geschwindigkeit"
msgid "Mean speed"
msgstr "Mittlere Geschwindigkeit"
msgid "Std dev (speed)"
msgstr "Standardabweichung (Geschwindigkeit)"
msgid "Groups"
msgstr "Gruppen"
msgid "group(s) detected"
msgstr "Gruppe(n) erkannt"
msgid "Group %(n)s"
msgstr "Gruppe %(n)s"
msgid "shot(s)"
msgstr "Schuss/Schüsse"
msgid "group(s)"
msgstr "Gruppe(n)"
msgid "Analysis"
msgstr "Analyse"
msgid "Welcome back, %(name)s."
msgstr "Willkommen zurück, %(name)s."
msgid "+ New session"
msgstr "+ Neue Sitzung"
msgid "+ Add equipment"
msgstr "+ Ausrüstung hinzufügen"
msgid "New analysis"
msgstr "Neue Analyse"
msgid "Recent Analyses"
msgstr "Neueste Analysen"
msgid "Title"
msgstr "Titel"
msgid "Shots"
msgstr "Schüsse"
msgid "Visibility"
msgstr "Sichtbarkeit"
msgid "No analyses yet."
msgstr "Noch keine Analysen."
msgid "Upload a CSV file"
msgstr "CSV-Datei hochladen"
msgid "to get started — it will be saved here automatically."
msgstr "um zu beginnen sie wird hier automatisch gespeichert."
msgid "Manage your rifles, scopes & gear →"
msgstr "Gewehre, Optiken und Ausrüstung verwalten →"
msgid "View your shooting sessions →"
msgstr "Schießsitzungen anzeigen →"
msgid "Sign in"
msgstr "Anmelden"
msgid "Password"
msgstr "Passwort"
msgid "Email"
msgstr "E-Mail"
msgid "Resend confirmation email"
msgstr "Bestätigungsmail erneut senden"
msgid "Don't have an account?"
msgstr "Kein Konto?"
msgid "Create one"
msgstr "Jetzt erstellen"
msgid "or continue with"
msgstr "oder weiter mit"
msgid "Continue with Google"
msgstr "Mit Google fortfahren"
msgid "Continue with GitHub"
msgstr "Mit GitHub fortfahren"
msgid "Create account"
msgstr "Konto erstellen"
msgid "(min. 8 characters)"
msgstr "(min. 8 Zeichen)"
msgid "Confirm password"
msgstr "Passwort bestätigen"
msgid "Already have an account?"
msgstr "Bereits ein Konto?"
msgid "Account"
msgstr "Konto"
msgid "Profile picture"
msgstr "Profilbild"
msgid "JPEG/PNG, max 1200 px, auto-resized."
msgstr "JPEG/PNG, max 1200 px, wird automatisch angepasst."
msgid "Display name"
msgstr "Anzeigename"
msgid "Bio"
msgstr "Bio"
msgid "Tell others a bit about yourself…"
msgstr "Erzählen Sie etwas über sich…"
msgid "Show my equipment on my public profile"
msgstr "Meine Ausrüstung im öffentlichen Profil anzeigen"
msgid "Save changes"
msgstr "Änderungen speichern"
msgid "View my public profile →"
msgstr "Mein öffentliches Profil ansehen →"
msgid "Change password"
msgstr "Passwort ändern"
msgid "Current password"
msgstr "Aktuelles Passwort"
msgid "New password"
msgstr "Neues Passwort"
msgid "Confirm"
msgstr "Bestätigen"
msgid "Member since %(date)s"
msgstr "Mitglied seit %(date)s"
msgid "Session"
msgstr "Sitzung"
msgid "Brand / Model"
msgstr "Marke / Modell"
msgid "No public sessions yet."
msgstr "Noch keine öffentlichen Sitzungen."
msgid "No equipment listed."
msgstr "Keine Ausrüstung angegeben."
msgid "My Equipment"
msgstr "Meine Ausrüstung"
msgid "+ Add item"
msgstr "+ Artikel hinzufügen"
msgid "View"
msgstr "Ansehen"
msgid "Delete %(name)s?"
msgstr "%(name)s löschen?"
msgid "No equipment yet."
msgstr "Noch keine Ausrüstung."
msgid "Add your first item"
msgstr "Ersten Artikel hinzufügen"
msgid "Add equipment"
msgstr "Ausrüstung hinzufügen"
msgid "Category *"
msgstr "Kategorie *"
msgid "Name *"
msgstr "Name *"
msgid "Brand"
msgstr "Marke"
msgid "Model"
msgstr "Modell"
msgid "Magnification"
msgstr "Vergrößerung"
msgid "Reticle"
msgstr "Absehen"
msgid "Unit"
msgstr "Einheit"
msgid "Serial number"
msgstr "Seriennummer"
msgid "Photo"
msgstr "Foto"
msgid "Upload a new one to replace it."
msgstr "Laden Sie eine neue hoch, um sie zu ersetzen."
msgid "My Sessions"
msgstr "Meine Sitzungen"
msgid "Type"
msgstr "Typ"
msgid "Long Range"
msgstr "Long Range"
msgid "25m Pistol"
msgstr "Pistole 25m"
msgid "Delete this session?"
msgstr "Diese Sitzung löschen?"
msgid "No sessions recorded yet."
msgstr "Noch keine Sitzungen aufgezeichnet."
msgid "Log your first session"
msgstr "Erste Sitzung erfassen"
msgid "New session"
msgstr "Neue Sitzung"
msgid "Choose the session type."
msgstr "Wählen Sie den Sitzungstyp."
msgid "Long Range Practice"
msgstr "Long Range Practice"
msgid "Long range precision shooting (100m+)"
msgstr "Präzisionsschießen auf lange Distanz (100m+)"
msgid "PRS"
msgstr "PRS"
msgid "Precision Rifle Series — training & competition"
msgstr "Precision Rifle Series — Training & Wettkampf"
msgid "25m precision pistol shooting"
msgstr "Präzisionsschießen Pistole 25m"
msgid "Stage management, PDF dope card ↗"
msgstr "Stage-Verwaltung, PDF-Dopekarte ↗"
msgid "← Change type"
msgstr "← Typ ändern"
msgid "Edit session"
msgstr "Sitzung bearbeiten"
msgid "Basic information"
msgstr "Grundinformationen"
msgid "Date *"
msgstr "Datum *"
msgid "Distance (m)"
msgstr "Distanz (m)"
msgid "Shooting position"
msgstr "Schießposition"
msgid "Weather"
msgstr "Wetter"
msgid "Conditions"
msgstr "Bedingungen"
msgid "Temp. (°C)"
msgstr "Temp. (°C)"
msgid "Wind (km/h)"
msgstr "Wind (km/h)"
msgid "Equipment & Ammunition"
msgstr "Ausrüstung & Munition"
msgid "Rifle / Handgun"
msgstr "Gewehr / Pistole"
msgid "— none —"
msgstr "— keine —"
msgid "Add a rifle first"
msgstr "Zuerst ein Gewehr hinzufügen"
msgid "Scope"
msgstr "Optik"
msgid "Ammo brand"
msgstr "Munitionsmarke"
msgid "Bullet weight (gr)"
msgstr "Geschossgewicht (gr)"
msgid "Lot number"
msgstr "Losnummer"
msgid "Notes & Visibility"
msgstr "Notizen & Sichtbarkeit"
msgid "Make this session public (visible in the community feed)"
msgstr "Diese Sitzung öffentlich machen (im Community-Feed sichtbar)"
msgid "Create session"
msgstr "Sitzung erstellen"
msgid "Session type:"
msgstr "Sitzungstyp:"
msgid "Delete this session? This cannot be undone."
msgstr "Diese Sitzung löschen? Dies kann nicht rückgängig gemacht werden."
msgid "km/h wind"
msgstr "km/h Wind"
msgid "Ammunition"
msgstr "Munition"
msgid "PRS Stages"
msgstr "PRS-Stages"
msgid "Time (s)"
msgstr "Zeit (s)"
msgid "Elevation Dope"
msgstr "Erhöhungs-Dope"
msgid "Windage Dope"
msgstr "Seiten-Dope"
msgid "Hits/Poss."
msgstr "Treffer/Mögl."
msgid "✏ Edit stages"
msgstr "✏ Stages bearbeiten"
msgid "📄 Generate dope card (PDF)"
msgstr "📄 Dopekarte erstellen (PDF)"
msgid "+ Add stage"
msgstr "+ Stage hinzufügen"
msgid "💾 Save"
msgstr "💾 Speichern"
msgid "Photos"
msgstr "Fotos"
msgid "Add photo"
msgstr "Foto hinzufügen"
msgid "Caption (optional)"
msgstr "Beschriftung (optional)"
msgid "Measure group"
msgstr "Gruppe messen"
msgid "Delete this photo?"
msgstr "Dieses Foto löschen?"
msgid "clean barrel"
msgstr "sauberer Lauf"
msgid "Group ES"
msgstr "Gruppen-ES"
msgid "Mean Radius"
msgstr "Mittlerer Radius"
msgid "Centre"
msgstr "Mittelpunkt"
msgid "high"
msgstr "oben"
msgid "low"
msgstr "unten"
msgid "Analyses"
msgstr "Analysen"
msgid "shots"
msgstr "Schüsse"
msgid "group"
msgstr "Gruppe"
msgid "groups"
msgstr "Gruppen"
msgid "m/s mean"
msgstr "m/s Mittel"
msgid "✏ Rename"
msgstr "✏ Umbenennen"
msgid "Full view"
msgstr "Vollansicht"
msgid "Delete this analysis? This cannot be undone."
msgstr "Diese Analyse löschen? Dies kann nicht rückgängig gemacht werden."
msgid "mean"
msgstr "Mittel"
msgid "SD"
msgstr "SA"
msgid "ES"
msgstr "ES"
msgid "Note"
msgstr "Notiz"
msgid "Save note"
msgstr "Notiz speichern"
msgid "CSV file missing — cannot display charts."
msgstr "CSV-Datei fehlt — Diagramme können nicht angezeigt werden."
msgid "Re-group settings"
msgstr "Gruppierungseinstellungen"
msgid "Outlier factor:"
msgstr "Ausreißer-Faktor:"
msgid "1 (fine)"
msgstr "1 (fein)"
msgid "20 (coarse)"
msgstr "20 (grob)"
msgid "Manual split indices (JSON array, e.g. [5, 12])"
msgstr "Manuelle Teilungsindizes (JSON-Array, z.B. [5, 12])"
msgid "Shot positions (0-based) where a new group should always begin."
msgstr "Schussposition (0-basiert), bei der eine neue Gruppe beginnen soll."
msgid "Upload chronograph CSV"
msgstr "Chronograph-CSV hochladen"
msgid "Analyse & link"
msgstr "Analysieren & verknüpfen"
msgid "Invalid email or password."
msgstr "Ungültige E-Mail-Adresse oder ungültiges Passwort."
msgid "Account created! Welcome."
msgstr "Konto erstellt! Willkommen."
msgid "Profile updated."
msgstr "Profil aktualisiert."
msgid "Password changed."
msgstr "Passwort geändert."
msgid "'%(name)s' added."
msgstr "'%(name)s' hinzugefügt."
msgid "'%(name)s' updated."
msgstr "'%(name)s' aktualisiert."
msgid "'%(name)s' deleted."
msgstr "'%(name)s' gelöscht."
msgid "Name is required."
msgstr "Name ist erforderlich."
msgid "Invalid category."
msgstr "Ungültige Kategorie."
msgid "Session saved."
msgstr "Sitzung gespeichert."
msgid "Session deleted."
msgstr "Sitzung gelöscht."
msgid "Stages saved."
msgstr "Stages gespeichert."
msgid "Photo deleted."
msgstr "Foto gelöscht."
msgid "Analysis deleted."
msgstr "Analyse gelöscht."
msgid "Title updated."
msgstr "Titel aktualisiert."
msgid "Regrouped."
msgstr "Neu gruppiert."
msgid "Note saved."
msgstr "Notiz gespeichert."
msgid "Please log in to access this page."
msgstr "Bitte melden Sie sich an, um auf diese Seite zuzugreifen."

Binary file not shown.

View File

@@ -0,0 +1,653 @@
msgid ""
msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: en\n"
msgid "New Analysis"
msgstr ""
msgid "Equipment"
msgstr ""
msgid "Sessions"
msgstr ""
msgid "Dashboard"
msgstr ""
msgid "Profile"
msgstr ""
msgid "Logout"
msgstr ""
msgid "Login"
msgstr ""
msgid "Join free"
msgstr ""
msgid "Save"
msgstr ""
msgid "Cancel"
msgstr ""
msgid "Edit"
msgstr ""
msgid "Delete"
msgstr ""
msgid "Apply"
msgstr ""
msgid "Upload"
msgstr ""
msgid "Notes"
msgstr ""
msgid "Public"
msgstr ""
msgid "Private"
msgstr ""
msgid "Date"
msgstr ""
msgid "Location"
msgstr ""
msgid "Name"
msgstr ""
msgid "Category"
msgstr ""
msgid "Caliber"
msgstr ""
msgid "Distance"
msgstr ""
msgid "Position"
msgstr ""
msgid "Log a Session"
msgstr ""
msgid "Get started — free"
msgstr ""
msgid "Try without account"
msgstr ""
msgid "Ballistic Analysis"
msgstr ""
msgid "Session Tracking"
msgstr ""
msgid "Community Feed"
msgstr ""
msgid "Upload CSV files from your chronograph and get instant shot-group statistics, velocity charts, and PDF reports."
msgstr ""
msgid "Log every range visit with location, weather, rifle, ammo, and distance. All your data in one place."
msgstr ""
msgid "Share your public sessions and see what other shooters are achieving on the range."
msgstr ""
msgid "Latest sessions"
msgstr ""
msgid "No public sessions yet. Be the first to share one!"
msgstr ""
msgid "Analyze"
msgstr ""
msgid "Upload a CSV file to analyse shot groups. The file must contain the following columns:"
msgstr ""
msgid "Analysis Results"
msgstr ""
msgid "← Upload another file"
msgstr ""
msgid "View saved report →"
msgstr ""
msgid "⬙ Download PDF report"
msgstr ""
msgid "Overall Statistics"
msgstr ""
msgid "Metric"
msgstr ""
msgid "Value"
msgstr ""
msgid "Total shots"
msgstr ""
msgid "Min speed"
msgstr ""
msgid "Max speed"
msgstr ""
msgid "Mean speed"
msgstr ""
msgid "Std dev (speed)"
msgstr ""
msgid "Groups"
msgstr ""
msgid "group(s) detected"
msgstr ""
msgid "Group %(n)s"
msgstr ""
msgid "shot(s)"
msgstr ""
msgid "group(s)"
msgstr ""
msgid "Analysis"
msgstr ""
msgid "Welcome back, %(name)s."
msgstr ""
msgid "+ New session"
msgstr ""
msgid "+ Add equipment"
msgstr ""
msgid "New analysis"
msgstr ""
msgid "Recent Analyses"
msgstr ""
msgid "Title"
msgstr ""
msgid "Shots"
msgstr ""
msgid "Visibility"
msgstr ""
msgid "No analyses yet."
msgstr ""
msgid "Upload a CSV file"
msgstr ""
msgid "to get started — it will be saved here automatically."
msgstr ""
msgid "Manage your rifles, scopes & gear →"
msgstr ""
msgid "View your shooting sessions →"
msgstr ""
msgid "Sign in"
msgstr ""
msgid "Password"
msgstr ""
msgid "Email"
msgstr ""
msgid "Resend confirmation email"
msgstr ""
msgid "Don't have an account?"
msgstr ""
msgid "Create one"
msgstr ""
msgid "or continue with"
msgstr ""
msgid "Continue with Google"
msgstr ""
msgid "Continue with GitHub"
msgstr ""
msgid "Create account"
msgstr ""
msgid "(min. 8 characters)"
msgstr ""
msgid "Confirm password"
msgstr ""
msgid "Already have an account?"
msgstr ""
msgid "Account"
msgstr ""
msgid "Profile picture"
msgstr ""
msgid "JPEG/PNG, max 1200 px, auto-resized."
msgstr ""
msgid "Display name"
msgstr ""
msgid "Bio"
msgstr ""
msgid "Tell others a bit about yourself…"
msgstr ""
msgid "Show my equipment on my public profile"
msgstr ""
msgid "Save changes"
msgstr ""
msgid "View my public profile →"
msgstr ""
msgid "Change password"
msgstr ""
msgid "Current password"
msgstr ""
msgid "New password"
msgstr ""
msgid "Confirm"
msgstr ""
msgid "Member since %(date)s"
msgstr ""
msgid "Session"
msgstr ""
msgid "Brand / Model"
msgstr ""
msgid "No public sessions yet."
msgstr ""
msgid "No equipment listed."
msgstr ""
msgid "My Equipment"
msgstr ""
msgid "+ Add item"
msgstr ""
msgid "View"
msgstr ""
msgid "Delete %(name)s?"
msgstr ""
msgid "No equipment yet."
msgstr ""
msgid "Add your first item"
msgstr ""
msgid "Add equipment"
msgstr ""
msgid "Category *"
msgstr ""
msgid "Name *"
msgstr ""
msgid "Brand"
msgstr ""
msgid "Model"
msgstr ""
msgid "Magnification"
msgstr ""
msgid "Reticle"
msgstr ""
msgid "Unit"
msgstr ""
msgid "Serial number"
msgstr ""
msgid "Photo"
msgstr ""
msgid "Upload a new one to replace it."
msgstr ""
msgid "My Sessions"
msgstr ""
msgid "Type"
msgstr ""
msgid "Long Range"
msgstr ""
msgid "25m Pistol"
msgstr ""
msgid "Delete this session?"
msgstr ""
msgid "No sessions recorded yet."
msgstr ""
msgid "Log your first session"
msgstr ""
msgid "New session"
msgstr ""
msgid "Choose the session type."
msgstr ""
msgid "Long Range Practice"
msgstr ""
msgid "Long range precision shooting (100m+)"
msgstr ""
msgid "PRS"
msgstr ""
msgid "Precision Rifle Series — training & competition"
msgstr ""
msgid "25m precision pistol shooting"
msgstr ""
msgid "Stage management, PDF dope card ↗"
msgstr ""
msgid "← Change type"
msgstr ""
msgid "Edit session"
msgstr ""
msgid "Basic information"
msgstr ""
msgid "Date *"
msgstr ""
msgid "Distance (m)"
msgstr ""
msgid "Shooting position"
msgstr ""
msgid "Weather"
msgstr ""
msgid "Conditions"
msgstr ""
msgid "Temp. (°C)"
msgstr ""
msgid "Wind (km/h)"
msgstr ""
msgid "Equipment & Ammunition"
msgstr ""
msgid "Rifle / Handgun"
msgstr ""
msgid "— none —"
msgstr ""
msgid "Add a rifle first"
msgstr ""
msgid "Scope"
msgstr ""
msgid "Ammo brand"
msgstr ""
msgid "Bullet weight (gr)"
msgstr ""
msgid "Lot number"
msgstr ""
msgid "Notes & Visibility"
msgstr ""
msgid "Make this session public (visible in the community feed)"
msgstr ""
msgid "Create session"
msgstr ""
msgid "Session type:"
msgstr ""
msgid "Delete this session? This cannot be undone."
msgstr ""
msgid "km/h wind"
msgstr ""
msgid "Ammunition"
msgstr ""
msgid "PRS Stages"
msgstr ""
msgid "Time (s)"
msgstr ""
msgid "Elevation Dope"
msgstr ""
msgid "Windage Dope"
msgstr ""
msgid "Hits/Poss."
msgstr ""
msgid "✏ Edit stages"
msgstr ""
msgid "📄 Generate dope card (PDF)"
msgstr ""
msgid "+ Add stage"
msgstr ""
msgid "💾 Save"
msgstr ""
msgid "Photos"
msgstr ""
msgid "Add photo"
msgstr ""
msgid "Caption (optional)"
msgstr ""
msgid "Measure group"
msgstr ""
msgid "Delete this photo?"
msgstr ""
msgid "clean barrel"
msgstr ""
msgid "Group ES"
msgstr ""
msgid "Mean Radius"
msgstr ""
msgid "Centre"
msgstr ""
msgid "high"
msgstr ""
msgid "low"
msgstr ""
msgid "Analyses"
msgstr ""
msgid "shots"
msgstr ""
msgid "group"
msgstr ""
msgid "groups"
msgstr ""
msgid "m/s mean"
msgstr ""
msgid "✏ Rename"
msgstr ""
msgid "Full view"
msgstr ""
msgid "Delete this analysis? This cannot be undone."
msgstr ""
msgid "mean"
msgstr ""
msgid "SD"
msgstr ""
msgid "ES"
msgstr ""
msgid "Note"
msgstr ""
msgid "Save note"
msgstr ""
msgid "CSV file missing — cannot display charts."
msgstr ""
msgid "Re-group settings"
msgstr ""
msgid "Outlier factor:"
msgstr ""
msgid "1 (fine)"
msgstr ""
msgid "20 (coarse)"
msgstr ""
msgid "Manual split indices (JSON array, e.g. [5, 12])"
msgstr ""
msgid "Shot positions (0-based) where a new group should always begin."
msgstr ""
msgid "Upload chronograph CSV"
msgstr ""
msgid "Analyse & link"
msgstr ""
msgid "Invalid email or password."
msgstr ""
msgid "Account created! Welcome."
msgstr ""
msgid "Profile updated."
msgstr ""
msgid "Password changed."
msgstr ""
msgid "'%(name)s' added."
msgstr ""
msgid "'%(name)s' updated."
msgstr ""
msgid "'%(name)s' deleted."
msgstr ""
msgid "Name is required."
msgstr ""
msgid "Invalid category."
msgstr ""
msgid "Session saved."
msgstr ""
msgid "Session deleted."
msgstr ""
msgid "Stages saved."
msgstr ""
msgid "Photo deleted."
msgstr ""
msgid "Analysis deleted."
msgstr ""
msgid "Title updated."
msgstr ""
msgid "Regrouped."
msgstr ""
msgid "Note saved."
msgstr ""
msgid "Please log in to access this page."
msgstr ""

Binary file not shown.

View File

@@ -0,0 +1,653 @@
msgid ""
msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: fr\n"
msgid "New Analysis"
msgstr "Nouvelle analyse"
msgid "Equipment"
msgstr "Équipement"
msgid "Sessions"
msgstr "Séances"
msgid "Dashboard"
msgstr "Tableau de bord"
msgid "Profile"
msgstr "Profil"
msgid "Logout"
msgstr "Déconnexion"
msgid "Login"
msgstr "Connexion"
msgid "Join free"
msgstr "Inscription gratuite"
msgid "Save"
msgstr "Enregistrer"
msgid "Cancel"
msgstr "Annuler"
msgid "Edit"
msgstr "Modifier"
msgid "Delete"
msgstr "Supprimer"
msgid "Apply"
msgstr "Appliquer"
msgid "Upload"
msgstr "Envoyer"
msgid "Notes"
msgstr "Notes"
msgid "Public"
msgstr "Public"
msgid "Private"
msgstr "Privé"
msgid "Date"
msgstr "Date"
msgid "Location"
msgstr "Lieu"
msgid "Name"
msgstr "Nom"
msgid "Category"
msgstr "Catégorie"
msgid "Caliber"
msgstr "Calibre"
msgid "Distance"
msgstr "Distance"
msgid "Position"
msgstr "Position"
msgid "Log a Session"
msgstr "Enregistrer une séance"
msgid "Get started — free"
msgstr "Commencer — gratuit"
msgid "Try without account"
msgstr "Essayer sans compte"
msgid "Ballistic Analysis"
msgstr "Analyse balistique"
msgid "Session Tracking"
msgstr "Suivi des séances"
msgid "Community Feed"
msgstr "Fil communautaire"
msgid "Upload CSV files from your chronograph and get instant shot-group statistics, velocity charts, and PDF reports."
msgstr "Importez des fichiers CSV de votre chrono et obtenez instantanément des statistiques de groupes, des graphiques de vélocité et des rapports PDF."
msgid "Log every range visit with location, weather, rifle, ammo, and distance. All your data in one place."
msgstr "Enregistrez chaque séance avec lieu, météo, arme, munitions et distance. Toutes vos données au même endroit."
msgid "Share your public sessions and see what other shooters are achieving on the range."
msgstr "Partagez vos séances publiques et voyez ce que les autres tireurs accomplissent sur le stand."
msgid "Latest sessions"
msgstr "Dernières séances"
msgid "No public sessions yet. Be the first to share one!"
msgstr "Aucune séance publique pour l'instant. Soyez le premier à en partager une !"
msgid "Analyze"
msgstr "Analyser"
msgid "Upload a CSV file to analyse shot groups. The file must contain the following columns:"
msgstr "Importez un fichier CSV pour analyser les groupes de tirs. Le fichier doit contenir les colonnes suivantes :"
msgid "Analysis Results"
msgstr "Résultats de l'analyse"
msgid "← Upload another file"
msgstr "← Importer un autre fichier"
msgid "View saved report →"
msgstr "Voir le rapport sauvegardé →"
msgid "⬙ Download PDF report"
msgstr "⬙ Télécharger le rapport PDF"
msgid "Overall Statistics"
msgstr "Statistiques globales"
msgid "Metric"
msgstr "Indicateur"
msgid "Value"
msgstr "Valeur"
msgid "Total shots"
msgstr "Tirs totaux"
msgid "Min speed"
msgstr "Vitesse min."
msgid "Max speed"
msgstr "Vitesse max."
msgid "Mean speed"
msgstr "Vitesse moyenne"
msgid "Std dev (speed)"
msgstr "Écart-type (vitesse)"
msgid "Groups"
msgstr "Groupes"
msgid "group(s) detected"
msgstr "groupe(s) détecté(s)"
msgid "Group %(n)s"
msgstr "Groupe %(n)s"
msgid "shot(s)"
msgstr "tir(s)"
msgid "group(s)"
msgstr "groupe(s)"
msgid "Analysis"
msgstr "Analyse"
msgid "Welcome back, %(name)s."
msgstr "Bienvenue, %(name)s."
msgid "+ New session"
msgstr "+ Nouvelle séance"
msgid "+ Add equipment"
msgstr "+ Ajouter du matériel"
msgid "New analysis"
msgstr "Nouvelle analyse"
msgid "Recent Analyses"
msgstr "Analyses récentes"
msgid "Title"
msgstr "Titre"
msgid "Shots"
msgstr "Tirs"
msgid "Visibility"
msgstr "Visibilité"
msgid "No analyses yet."
msgstr "Aucune analyse pour l'instant."
msgid "Upload a CSV file"
msgstr "Importez un fichier CSV"
msgid "to get started — it will be saved here automatically."
msgstr "pour commencer — il sera automatiquement sauvegardé ici."
msgid "Manage your rifles, scopes & gear →"
msgstr "Gérez vos armes, optiques et équipement →"
msgid "View your shooting sessions →"
msgstr "Voir vos séances de tir →"
msgid "Sign in"
msgstr "Connexion"
msgid "Password"
msgstr "Mot de passe"
msgid "Email"
msgstr "E-mail"
msgid "Resend confirmation email"
msgstr "Renvoyer l'e-mail de confirmation"
msgid "Don't have an account?"
msgstr "Pas de compte ?"
msgid "Create one"
msgstr "Créez-en un"
msgid "or continue with"
msgstr "ou continuer avec"
msgid "Continue with Google"
msgstr "Continuer avec Google"
msgid "Continue with GitHub"
msgstr "Continuer avec GitHub"
msgid "Create account"
msgstr "Créer un compte"
msgid "(min. 8 characters)"
msgstr "(min. 8 caractères)"
msgid "Confirm password"
msgstr "Confirmer le mot de passe"
msgid "Already have an account?"
msgstr "Vous avez déjà un compte ?"
msgid "Account"
msgstr "Compte"
msgid "Profile picture"
msgstr "Photo de profil"
msgid "JPEG/PNG, max 1200 px, auto-resized."
msgstr "JPEG/PNG, max 1200 px, redimensionné automatiquement."
msgid "Display name"
msgstr "Nom d'affichage"
msgid "Bio"
msgstr "Bio"
msgid "Tell others a bit about yourself…"
msgstr "Parlez un peu de vous…"
msgid "Show my equipment on my public profile"
msgstr "Afficher mon équipement sur mon profil public"
msgid "Save changes"
msgstr "Enregistrer les modifications"
msgid "View my public profile →"
msgstr "Voir mon profil public →"
msgid "Change password"
msgstr "Changer le mot de passe"
msgid "Current password"
msgstr "Mot de passe actuel"
msgid "New password"
msgstr "Nouveau mot de passe"
msgid "Confirm"
msgstr "Confirmer"
msgid "Member since %(date)s"
msgstr "Membre depuis %(date)s"
msgid "Session"
msgstr "Séance"
msgid "Brand / Model"
msgstr "Marque / Modèle"
msgid "No public sessions yet."
msgstr "Aucune séance publique pour l'instant."
msgid "No equipment listed."
msgstr "Aucun équipement renseigné."
msgid "My Equipment"
msgstr "Mon équipement"
msgid "+ Add item"
msgstr "+ Ajouter un article"
msgid "View"
msgstr "Voir"
msgid "Delete %(name)s?"
msgstr "Supprimer %(name)s ?"
msgid "No equipment yet."
msgstr "Aucun équipement pour l'instant."
msgid "Add your first item"
msgstr "Ajoutez votre premier article"
msgid "Add equipment"
msgstr "Ajouter du matériel"
msgid "Category *"
msgstr "Catégorie *"
msgid "Name *"
msgstr "Nom *"
msgid "Brand"
msgstr "Marque"
msgid "Model"
msgstr "Modèle"
msgid "Magnification"
msgstr "Grossissement"
msgid "Reticle"
msgstr "Réticule"
msgid "Unit"
msgstr "Unité"
msgid "Serial number"
msgstr "Numéro de série"
msgid "Photo"
msgstr "Photo"
msgid "Upload a new one to replace it."
msgstr "Importez-en une nouvelle pour la remplacer."
msgid "My Sessions"
msgstr "Mes séances"
msgid "Type"
msgstr "Type"
msgid "Long Range"
msgstr "Long Range"
msgid "25m Pistol"
msgstr "Pistolet 25m"
msgid "Delete this session?"
msgstr "Supprimer cette séance ?"
msgid "No sessions recorded yet."
msgstr "Aucune séance enregistrée pour l'instant."
msgid "Log your first session"
msgstr "Enregistrez votre première séance"
msgid "New session"
msgstr "Nouvelle séance"
msgid "Choose the session type."
msgstr "Choisissez le type de séance."
msgid "Long Range Practice"
msgstr "Long Range Practice"
msgid "Long range precision shooting (100m+)"
msgstr "Tir de précision longue distance (100 m+)"
msgid "PRS"
msgstr "PRS"
msgid "Precision Rifle Series — training & competition"
msgstr "Precision Rifle Series — entraînement & compétition"
msgid "25m precision pistol shooting"
msgstr "Tir de précision pistolet à 25 m"
msgid "Stage management, PDF dope card ↗"
msgstr "Gestion des stages, fiche de tir PDF ↗"
msgid "← Change type"
msgstr "← Changer le type"
msgid "Edit session"
msgstr "Modifier la séance"
msgid "Basic information"
msgstr "Informations de base"
msgid "Date *"
msgstr "Date *"
msgid "Distance (m)"
msgstr "Distance (m)"
msgid "Shooting position"
msgstr "Position de tir"
msgid "Weather"
msgstr "Météo"
msgid "Conditions"
msgstr "Conditions"
msgid "Temp. (°C)"
msgstr "Temp. (°C)"
msgid "Wind (km/h)"
msgstr "Vent (km/h)"
msgid "Equipment & Ammunition"
msgstr "Équipement & Munitions"
msgid "Rifle / Handgun"
msgstr "Arme"
msgid "— none —"
msgstr "— aucune —"
msgid "Add a rifle first"
msgstr "Ajouter une arme d'abord"
msgid "Scope"
msgstr "Optique"
msgid "Ammo brand"
msgstr "Marque munitions"
msgid "Bullet weight (gr)"
msgstr "Poids ogive (gr)"
msgid "Lot number"
msgstr "N° de lot"
msgid "Notes & Visibility"
msgstr "Notes & Visibilité"
msgid "Make this session public (visible in the community feed)"
msgstr "Rendre cette séance publique (visible dans le fil communautaire)"
msgid "Create session"
msgstr "Créer la séance"
msgid "Session type:"
msgstr "Type de séance :"
msgid "Delete this session? This cannot be undone."
msgstr "Supprimer cette séance ? Cette action est irréversible."
msgid "km/h wind"
msgstr "km/h de vent"
msgid "Ammunition"
msgstr "Munitions"
msgid "PRS Stages"
msgstr "Stages PRS"
msgid "Time (s)"
msgstr "Temps (s)"
msgid "Elevation Dope"
msgstr "Dope Élév."
msgid "Windage Dope"
msgstr "Dope Dérive"
msgid "Hits/Poss."
msgstr "Coups/Poss."
msgid "✏ Edit stages"
msgstr "✏ Modifier les stages"
msgid "📄 Generate dope card (PDF)"
msgstr "📄 Générer la fiche de tir (PDF)"
msgid "+ Add stage"
msgstr "+ Ajouter un stage"
msgid "💾 Save"
msgstr "💾 Enregistrer"
msgid "Photos"
msgstr "Photos"
msgid "Add photo"
msgstr "Ajouter une photo"
msgid "Caption (optional)"
msgstr "Légende (optionnel)"
msgid "Measure group"
msgstr "Mesurer le groupe"
msgid "Delete this photo?"
msgstr "Supprimer cette photo ?"
msgid "clean barrel"
msgstr "canon propre"
msgid "Group ES"
msgstr "ES du groupe"
msgid "Mean Radius"
msgstr "Rayon moyen"
msgid "Centre"
msgstr "Centre"
msgid "high"
msgstr "haut"
msgid "low"
msgstr "bas"
msgid "Analyses"
msgstr "Analyses"
msgid "shots"
msgstr "tirs"
msgid "group"
msgstr "groupe"
msgid "groups"
msgstr "groupes"
msgid "m/s mean"
msgstr "m/s moyen"
msgid "✏ Rename"
msgstr "✏ Renommer"
msgid "Full view"
msgstr "Vue complète"
msgid "Delete this analysis? This cannot be undone."
msgstr "Supprimer cette analyse ? Cette action est irréversible."
msgid "mean"
msgstr "moyen"
msgid "SD"
msgstr "ET"
msgid "ES"
msgstr "ES"
msgid "Note"
msgstr "Note"
msgid "Save note"
msgstr "Enregistrer la note"
msgid "CSV file missing — cannot display charts."
msgstr "Fichier CSV manquant — impossible d'afficher les graphiques."
msgid "Re-group settings"
msgstr "Paramètres de regroupement"
msgid "Outlier factor:"
msgstr "Facteur d'anomalie :"
msgid "1 (fine)"
msgstr "1 (fin)"
msgid "20 (coarse)"
msgstr "20 (grossier)"
msgid "Manual split indices (JSON array, e.g. [5, 12])"
msgstr "Indices de coupure manuels (tableau JSON, ex. [5, 12])"
msgid "Shot positions (0-based) where a new group should always begin."
msgstr "Positions de tir (base 0) où un nouveau groupe doit toujours commencer."
msgid "Upload chronograph CSV"
msgstr "Importer le CSV du chronographe"
msgid "Analyse & link"
msgstr "Analyser & lier"
msgid "Invalid email or password."
msgstr "Identifiant ou mot de passe incorrect."
msgid "Account created! Welcome."
msgstr "Compte créé ! Bienvenue."
msgid "Profile updated."
msgstr "Profil mis à jour."
msgid "Password changed."
msgstr "Mot de passe modifié."
msgid "'%(name)s' added."
msgstr "'%(name)s' ajouté."
msgid "'%(name)s' updated."
msgstr "'%(name)s' mis à jour."
msgid "'%(name)s' deleted."
msgstr "'%(name)s' supprimé."
msgid "Name is required."
msgstr "Le nom est obligatoire."
msgid "Invalid category."
msgstr "Catégorie invalide."
msgid "Session saved."
msgstr "Séance sauvegardée."
msgid "Session deleted."
msgstr "Séance supprimée."
msgid "Stages saved."
msgstr "Stages sauvegardés."
msgid "Photo deleted."
msgstr "Photo supprimée."
msgid "Analysis deleted."
msgstr "Analyse supprimée."
msgid "Title updated."
msgstr "Titre mis à jour."
msgid "Regrouped."
msgstr "Regroupé."
msgid "Note saved."
msgstr "Note sauvegardée."
msgid "Please log in to access this page."
msgstr "Veuillez vous connecter pour accéder à cette page."