wip: claude

This commit is contained in:
Gérald Colangelo
2026-03-23 11:39:51 +01:00
parent 04e8631a3a
commit a4dad2a9f2
14 changed files with 707 additions and 135 deletions

86
CLAUDE.md Normal file
View File

@@ -0,0 +1,86 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
**First-time setup:**
```bash
cp .env.example .env # fill in SECRET_KEY, DB_PASSWORD, OAuth credentials
```
**Run the app (only way to test — no host Python):**
```bash
docker compose up --build
```
**Database migrations:**
```bash
# Applied automatically on every `docker compose up` via entrypoint.sh.
# To create a new migration after changing models.py (DB must be running):
DB_PASS=$(grep DB_PASSWORD .env | cut -d= -f2)
docker run --rm -v "$(pwd)":/app -w /app \
-e FLASK_APP=app -e DATABASE_URL="postgresql+psycopg://ballistic:${DB_PASS}@db:5432/ballistic" \
-e SECRET_KEY=dev --network ballistictool_default --entrypoint flask \
ballistictool-web db migrate -m "description"
# Then restart so entrypoint applies it:
docker compose up --build -d
```
> **Important:** `docker compose run web flask db ...` won't work for init/migrate because the container is ephemeral and writes to its own filesystem. Use the bind-mount `docker run` form above so files persist to the host and get committed to git.
**Smoke-test imports without starting the DB:**
```bash
docker compose run --no-deps --rm --entrypoint python web -c "from app import create_app; create_app()"
```
> The host Python environment is externally-managed (Debian). Do not run `pip install` on the host. All dependency testing must happen inside Docker.
## Project structure
```
app.py — create_app() factory; registers extensions, blueprints, and core routes
config.py — Config class reading all env vars (SECRET_KEY, DATABASE_URL, OAuth keys)
extensions.py — module-level db/login_manager/migrate instances (no init_app here)
models.py — SQLAlchemy models: User, EquipmentItem, ShootingSession, Analysis
storage.py — file I/O helpers: save_analysis(), save_equipment_photo()
blueprints/ — feature blueprints (auth, dashboard, analyses, equipment, sessions)
migrations/ — Alembic migration scripts (committed to git)
.env — gitignored secrets (copy from .env.example)
entrypoint.sh — runs `flask db upgrade` then starts gunicorn
```
## Architecture
Flask web app that processes ballistic CSV data, computes statistics, renders charts, and generates PDF reports.
**Request flow for `POST /analyze`:**
```
Upload CSV
→ analyzer/parser.py parse_csv() — normalize CSV (handles locale variants)
→ analyzer/grouper.py detect_groups() — split into shot groups by time gaps
→ analyzer/stats.py compute_*_stats() — per-group + overall statistics
→ analyzer/charts.py render_*_charts() — base64 PNG images via matplotlib
→ analyzer/pdf_report.py generate_pdf() — fpdf2 multi-page PDF (returned as bytes)
→ templates/results.html — renders stats + embedded images + PDF link
```
**Group detection algorithm** (`grouper.py`): splits shots where the gap between consecutive timestamps exceeds `median_gap × OUTLIER_FACTOR` (5). This is the core domain logic that determines what counts as a separate shooting session.
**CSV parsing** (`parser.py`): handles BOM, various decimal separators (`.` / `,`), and time formats (`HH:MM:SS`, `HH:MM:SS.fff`, `HH:MM:SS,fff`). Expected columns map French headers to internal names: `speed`, `std_dev`, `energy`, `power_factor`, `time`.
**Charts** use matplotlib's `Agg` (non-interactive) backend. Images are base64-encoded and embedded directly in HTML and PDF — no static asset serving.
**PDF** is returned as raw bytes from `generate_pdf()` and served inline via Flask's `send_file`.
## Stack
- Python 3.12, Flask 3.0, gunicorn (2 workers, port 5000)
- PostgreSQL 16 via Docker Compose; SQLAlchemy 2.0 + Flask-Migrate (Alembic) for ORM/migrations
- DB driver: `psycopg[binary]` (psycopg3) — connection URL scheme is `postgresql+psycopg://`
- Auth: Authlib (OAuth2 flows) + Flask-Login (session/`current_user`); providers: Google, GitHub
- File storage: Docker volume at `/app/storage`; Pillow for equipment photo validation/resize
- pandas + numpy for data processing; matplotlib for charts; fpdf2 for PDF generation
- Docker / Docker Compose for deployment (no host install)

View File

@@ -5,17 +5,39 @@ OUTLIER_FACTOR = 5
def detect_groups(df: pd.DataFrame, outlier_factor: float = OUTLIER_FACTOR,
manual_splits: list | None = None) -> list:
manual_splits: list | None = None,
forced_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.
forced_splits: when provided, ONLY these split positions are used — auto-detection
is bypassed entirely. Use this for user-defined groupings from the visual editor.
manual_splits: added on top of auto-detected splits (when forced_splits is None).
Both auto+manual mechanisms are merged and deduplicated.
"""
if len(df) <= 1:
return [df]
def _build_groups(all_splits):
if not all_splits:
return [df]
groups = []
prev = 0
for pos in all_splits:
group = df.iloc[prev:pos]
if len(group) > 0:
groups.append(group.reset_index(drop=True))
prev = pos
last = df.iloc[prev:]
if len(last) > 0:
groups.append(last.reset_index(drop=True))
return groups
# Forced mode: user controls exact split positions, no auto-detection
if forced_splits is not None:
valid = sorted(s for s in forced_splits if 0 < s < len(df))
return _build_groups(valid)
times = df["time"]
diffs = times.diff().dropna()
@@ -35,20 +57,4 @@ def detect_groups(df: pd.DataFrame, outlier_factor: float = OUTLIER_FACTOR,
# Merge with manual splits (filter to valid range)
extra = set(manual_splits) if manual_splits else set()
all_splits = sorted(auto_splits | extra)
if not all_splits:
return [df]
groups = []
prev = 0
for pos in all_splits:
group = df.iloc[prev:pos]
if len(group) > 0:
groups.append(group.reset_index(drop=True))
prev = pos
last = df.iloc[prev:]
if len(last) > 0:
groups.append(last.reset_index(drop=True))
return groups
return _build_groups(sorted(auto_splits | extra))

View File

@@ -10,7 +10,7 @@ from flask_babel import _
from flask_login import current_user, login_required
from extensions import db
from models import Analysis
from models import Analysis, AnalysisGroupPhoto
analyses_bp = Blueprint("analyses", __name__, url_prefix="/analyses")
@@ -116,22 +116,6 @@ def regroup(analysis_id: int):
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():
@@ -144,9 +128,46 @@ def regroup(analysis_id: int):
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)
# Check if this is a forced-split (visual editor) submission
forced_splits_raw = request.form.get("forced_splits", "").strip()
forced_splits = None
if forced_splits_raw:
try:
parsed = json.loads(forced_splits_raw)
if isinstance(parsed, list):
forced_splits = [int(x) for x in parsed]
except (json.JSONDecodeError, ValueError, TypeError):
pass
if forced_splits is not None:
# Visual editor mode: exact user-defined group boundaries
new_outlier_factor = None
new_manual_splits = forced_splits
csv_bytes = csv_path.read_bytes()
df = parse_csv(io.BytesIO(csv_bytes))
groups = detect_groups(df, forced_splits=forced_splits)
else:
# Traditional mode: outlier_factor + optional manual_splits
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
new_outlier_factor = outlier_factor
new_manual_splits = manual_splits
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)
@@ -161,14 +182,14 @@ def regroup(analysis_id: int):
pdf_bytes = generate_pdf(overall, group_stats, charts, overview_chart)
if a.pdf_path:
pdf_path = Path(storage_root) / a.pdf_path
pdf_path_obj = Path(storage_root) / a.pdf_path
try:
pdf_path.write_bytes(pdf_bytes)
pdf_path_obj.write_bytes(pdf_bytes)
except Exception:
pass
a.grouping_outlier_factor = outlier_factor
a.grouping_manual_splits = manual_splits
a.grouping_outlier_factor = new_outlier_factor
a.grouping_manual_splits = new_manual_splits
a.group_stats = _to_python(group_stats)
a.overall_stats = _to_python(overall)
a.shot_count = int(overall.get("count", 0))
@@ -219,3 +240,98 @@ def download_pdf(analysis_id: int):
filename = Path(a.pdf_path).name
return send_from_directory(pdf_dir, filename, as_attachment=True,
download_name=f"{a.title}.pdf")
# ---------------------------------------------------------------------------
# Analysis group photos
# ---------------------------------------------------------------------------
@analyses_bp.route("/<int:analysis_id>/groups/<int:group_index>/photo", methods=["POST"])
@login_required
def upload_group_photo(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)
photo_file = request.files.get("photo")
if not photo_file or not photo_file.filename:
flash(_("No photo selected."), "error")
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)
from storage import save_analysis_group_photo
try:
photo_path = save_analysis_group_photo(
current_user.id, a.id, group_index, photo_file
)
except ValueError as e:
flash(str(e), "error")
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)
caption = request.form.get("caption", "").strip() or None
photo = AnalysisGroupPhoto(
analysis_id=a.id,
group_index=group_index,
photo_path=photo_path,
caption=caption,
)
db.session.add(photo)
db.session.commit()
flash(_("Photo added."), "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("/group-photos/<int:photo_id>/delete", methods=["POST"])
@login_required
def delete_group_photo(photo_id: int):
photo = db.session.get(AnalysisGroupPhoto, photo_id)
if photo is None:
abort(404)
if photo.analysis.user_id != current_user.id:
abort(403)
storage_root = current_app.config["STORAGE_ROOT"]
try:
(Path(storage_root) / photo.photo_path).unlink(missing_ok=True)
except Exception:
pass
analysis_id = photo.analysis_id
session_id = photo.analysis.session_id
db.session.delete(photo)
db.session.commit()
flash(_("Photo deleted."), "success")
back = (url_for("sessions.detail", session_id=session_id)
if session_id else url_for("analyses.detail", analysis_id=analysis_id))
return redirect(back)
@analyses_bp.route("/group-photos/<path:filepath>")
def serve_group_photo(filepath: str):
"""Serve an analysis group photo. Private analysis photos are owner-only."""
try:
owner_id = int(filepath.split("/")[0])
except (ValueError, IndexError):
abort(404)
is_owner = current_user.is_authenticated and current_user.id == owner_id
if not is_owner:
# Check if analysis is public
from sqlalchemy import select
photo = db.session.scalars(
select(AnalysisGroupPhoto).where(
AnalysisGroupPhoto.photo_path == f"analysis_group_photos/{filepath}"
)
).first()
if photo is None or not photo.analysis.is_public:
abort(403)
storage_root = current_app.config["STORAGE_ROOT"]
return send_from_directory(Path(storage_root) / "analysis_group_photos", filepath)

View File

@@ -15,13 +15,17 @@ from storage import rotate_photo, save_equipment_photo
equipment_bp = Blueprint("equipment", __name__, url_prefix="/equipment")
CATEGORIES = [
_CATEGORIES_RAW = [
("rifle", "Rifle"),
("handgun", "Handgun"),
("scope", "Scope"),
("other", "Other"),
]
CATEGORY_KEYS = [k for k, _ in CATEGORIES]
CATEGORY_KEYS = [k for k, _ in _CATEGORIES_RAW]
def _t_categories():
return [(k, _(l)) for k, l in _CATEGORIES_RAW]
# ---------------------------------------------------------------------------
@@ -76,7 +80,7 @@ def index():
.where(EquipmentItem.user_id == current_user.id)
.order_by(EquipmentItem.category, EquipmentItem.name)
).all()
return render_template("equipment/list.html", items=items, categories=CATEGORIES)
return render_template("equipment/list.html", items=items, categories=_t_categories())
@equipment_bp.route("/new", methods=["GET", "POST"])
@@ -90,20 +94,20 @@ def new():
db.session.expunge(item)
flash(error, "error")
return render_template("equipment/form.html", item=None,
categories=CATEGORIES, prefill=request.form)
categories=_t_categories(), prefill=request.form)
db.session.flush()
_handle_photo(item, is_new=True)
db.session.commit()
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)
return render_template("equipment/form.html", item=None, categories=_t_categories())
@equipment_bp.route("/<int:item_id>")
@login_required
def detail(item_id: int):
item = _own_item(item_id)
return render_template("equipment/detail.html", item=item, categories=dict(CATEGORIES))
return render_template("equipment/detail.html", item=item, categories=dict(_t_categories()))
@equipment_bp.route("/<int:item_id>/edit", methods=["GET", "POST"])
@@ -115,12 +119,12 @@ def edit(item_id: int):
if error:
flash(error, "error")
return render_template("equipment/form.html", item=item,
categories=CATEGORIES, prefill=request.form)
categories=_t_categories(), prefill=request.form)
_handle_photo(item, is_new=False)
db.session.commit()
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)
return render_template("equipment/form.html", item=item, categories=_t_categories())
@equipment_bp.route("/<int:item_id>/delete", methods=["POST"])

View File

@@ -64,6 +64,23 @@ WEATHER_CONDITIONS = [
]
# ---------------------------------------------------------------------------
# Translation helpers (call at request time, not module level)
# ---------------------------------------------------------------------------
def _t_weather():
return [(v, _(l)) for v, l in WEATHER_CONDITIONS]
def _t_positions(pairs):
return [(v, _(l)) for v, l in pairs]
def _t_session_types():
return [(s, _(n), _(d)) for s, n, d in SESSION_TYPES]
def _t_prs_positions():
return [(v, _(l)) for v, l in PRS_STAGE_POSITIONS]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@@ -172,10 +189,10 @@ def new():
flash(error, "error")
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,
weather_conditions=_t_weather(),
session_types=_t_session_types(),
long_range_positions=_t_positions(LONG_RANGE_POSITIONS),
pistol_25m_positions=_t_positions(PISTOL_25M_POSITIONS),
prefill=request.form)
db.session.commit()
flash(_("Session saved."), "success")
@@ -186,15 +203,15 @@ def new():
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,
weather_conditions=_t_weather(),
session_types=_t_session_types(),
long_range_positions=_t_positions(LONG_RANGE_POSITIONS),
pistol_25m_positions=_t_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)
return render_template("sessions/type_picker.html", session_types=_t_session_types())
@sessions_bp.route("/<int:session_id>")
@@ -217,15 +234,20 @@ def detail(session_id: int):
from analyzer.charts import render_group_charts, render_overview_chart
storage_root = current_app.config["STORAGE_ROOT"]
prs_positions = _t_prs_positions()
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)
if a.grouping_outlier_factor is None and splits is not None:
# Forced mode: user defined exact split positions
groups = detect_groups(df, forced_splits=splits)
else:
factor = a.grouping_outlier_factor if a.grouping_outlier_factor is not None else OUTLIER_FACTOR
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
@@ -238,14 +260,21 @@ def detail(session_id: int):
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))
# Compute split positions from cumulative group counts (for visual editor)
cumulative = 0
split_positions = []
for gs, _chart in groups_display[:-1]:
cumulative += gs["count"]
split_positions.append(cumulative)
analyses_display.append((a, groups_display, overview_chart, split_positions))
except Exception:
analyses_display.append((a, None, None))
analyses_display.append((a, None, None, []))
else:
analyses_display.append((a, None, None))
analyses_display.append((a, None, None, []))
return render_template("sessions/detail.html", session=s,
analyses=analyses, analyses_display=analyses_display,
prs_positions=prs_positions,
is_owner=is_owner)
@@ -259,10 +288,10 @@ def edit(session_id: int):
flash(error, "error")
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,
weather_conditions=_t_weather(),
session_types=_t_session_types(),
long_range_positions=_t_positions(LONG_RANGE_POSITIONS),
pistol_25m_positions=_t_positions(PISTOL_25M_POSITIONS),
prefill=request.form)
for analysis in s.analyses:
analysis.is_public = s.is_public
@@ -271,10 +300,10 @@ def edit(session_id: int):
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,
session_types=SESSION_TYPES,
long_range_positions=LONG_RANGE_POSITIONS,
pistol_25m_positions=PISTOL_25M_POSITIONS)
weather_conditions=_t_weather(),
session_types=_t_session_types(),
long_range_positions=_t_positions(LONG_RANGE_POSITIONS),
pistol_25m_positions=_t_positions(PISTOL_25M_POSITIONS))
@sessions_bp.route("/<int:session_id>/delete", methods=["POST"])

View File

@@ -0,0 +1,34 @@
"""analysis_group_photos
Revision ID: a9f3d82c1e47
Revises: 6818f37f4124
Create Date: 2026-03-20 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a9f3d82c1e47'
down_revision = '6818f37f4124'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'analysis_group_photos',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('analysis_id', sa.Integer(), nullable=False),
sa.Column('group_index', sa.Integer(), nullable=False),
sa.Column('photo_path', sa.Text(), nullable=False),
sa.Column('caption', sa.String(length=255), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['analysis_id'], ['analyses.id'], ),
sa.PrimaryKeyConstraint('id'),
)
def downgrade():
op.drop_table('analysis_group_photos')

View File

@@ -162,6 +162,10 @@ class Analysis(db.Model):
user: Mapped["User"] = relationship("User", back_populates="analyses")
session: Mapped["ShootingSession | None"] = relationship("ShootingSession", back_populates="analyses")
group_photos: Mapped[list["AnalysisGroupPhoto"]] = relationship(
"AnalysisGroupPhoto", back_populates="analysis", cascade="all, delete-orphan",
order_by="AnalysisGroupPhoto.group_index, AnalysisGroupPhoto.created_at"
)
class SessionPhoto(db.Model):
@@ -181,3 +185,21 @@ class SessionPhoto(db.Model):
def photo_url(self) -> str:
rel = self.photo_path.removeprefix("session_photos/")
return f"/sessions/photos/{rel}"
class AnalysisGroupPhoto(db.Model):
__tablename__ = "analysis_group_photos"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
analysis_id: Mapped[int] = mapped_column(ForeignKey("analyses.id"), nullable=False)
group_index: Mapped[int] = mapped_column(Integer, nullable=False)
photo_path: Mapped[str] = mapped_column(Text, nullable=False)
caption: Mapped[str | None] = mapped_column(String(255))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
analysis: Mapped["Analysis"] = relationship("Analysis", back_populates="group_photos")
@property
def photo_url(self) -> str:
rel = self.photo_path.removeprefix("analysis_group_photos/")
return f"/analyses/group-photos/{rel}"

View File

@@ -143,6 +143,12 @@ def save_avatar(user_id: int, file_storage) -> str:
return _save_photo(file_storage, dest, "avatar")
def save_analysis_group_photo(user_id: int, analysis_id: int, group_index: int,
file_storage) -> str:
dest = _root() / "analysis_group_photos" / str(user_id)
return _save_photo(file_storage, dest, f"{analysis_id}_g{group_index}")
def rotate_photo(rel_path: str, degrees: int) -> None:
"""Rotate a stored JPEG in-place. degrees is clockwise (90, -90, 180)."""
path = _root() / rel_path

View File

@@ -158,15 +158,7 @@
<script>
(function () {
var PRS_POS = [
["prone", "Couché"],
["standing", "Debout"],
["kneeling", "Agenouillé"],
["sitting", "Assis"],
["barricade", "Barricade"],
["rooftop", "Toit"],
["unknown", "Variable"],
];
var PRS_POS = {{ prs_positions | tojson }};
var stages = {{ (session.prs_stages or []) | tojson }};
@@ -434,7 +426,7 @@
<h2>Analyses{% if analyses %} ({{ analyses|length }}){% endif %}</h2>
{% if analyses_display %}
{% for a, groups_display, overview_chart in analyses_display %}
{% for a, groups_display, overview_chart, split_positions 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;">
@@ -502,8 +494,10 @@
{# --- Per-group cards --- #}
{% if groups_display %}
{% for gs, chart in groups_display %}
<div class="group-section" style="margin-bottom:1rem;">
<div class="group-meta">
{% set grp_idx = loop.index0 %}
{% set grp_photos = a.group_photos | selectattr('group_index', 'equalto', grp_idx) | list %}
<div class="group-section" style="margin-bottom:1.25rem;border:1px solid #eee;border-radius:6px;padding:.9rem 1rem;">
<div class="group-meta" style="margin-bottom:.6rem;">
<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
@@ -517,24 +511,75 @@
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>
{# Group photos #}
{% if grp_photos %}
<div style="display:flex;flex-wrap:wrap;gap:.75rem;margin-top:.75rem;">
{% for gp in grp_photos %}
<div>
<img src="{{ gp.photo_url }}" alt="{{ gp.caption or '' }}"
style="height:120px;width:auto;border-radius:5px;object-fit:cover;display:block;">
{% if gp.caption %}
<div style="font-size:0.75rem;color:#666;margin-top:.2rem;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ gp.caption }}</div>
{% endif %}
{% if is_owner %}
<form method="post" action="{{ url_for('analyses.delete_group_photo', photo_id=gp.id) }}"
onsubmit="return confirm('{{ _('Delete this photo?') | e }}');"
style="margin-top:.2rem;">
<button type="submit"
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.35rem .85rem;font-size:0.82rem;cursor:pointer;">
{{ _('Save note') }}
style="background:#fff0f0;color:#c0392b;border:1px solid #f5c6c6;border-radius:3px;padding:.1rem .4rem;font-size:0.75rem;cursor:pointer;">
{{ _('Delete') }}
</button>
</div>
</form>
</details>
</form>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{% if is_owner %}
<div style="display:flex;flex-wrap:wrap;gap:.75rem;margin-top:.75rem;align-items:flex-start;">
{# Note editor #}
<details>
<summary style="font-size:0.82rem;color:#888;cursor:pointer;list-style:none;display:inline-block;padding:.2rem .5rem;border:1px solid #ddd;border-radius:4px;background:#fafafa;">
&#9998; {{ _('Note') }}
</summary>
<form method="post"
action="{{ url_for('analyses.save_group_note', analysis_id=a.id, group_index=grp_idx) }}"
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:400px;">{{ gs.note or '' }}</textarea>
<div>
<button type="submit"
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.3rem .75rem;font-size:0.82rem;cursor:pointer;">
{{ _('Save note') }}
</button>
</div>
</form>
</details>
{# Group photo upload #}
<details>
<summary style="font-size:0.82rem;color:#888;cursor:pointer;list-style:none;display:inline-block;padding:.2rem .5rem;border:1px solid #ddd;border-radius:4px;background:#fafafa;">
&#128247; {{ _('Add photo') }}
</summary>
<form method="post"
action="{{ url_for('analyses.upload_group_photo', analysis_id=a.id, group_index=grp_idx) }}"
enctype="multipart/form-data"
style="margin-top:.4rem;display:flex;flex-wrap:wrap;gap:.5rem;align-items:flex-end;">
<div>
<input type="file" name="photo" accept="image/*" required style="font-size:0.85rem;">
</div>
<div>
<input type="text" name="caption" placeholder="{{ _('Caption (optional)') }}"
style="padding:.35rem .6rem;border:1px solid #ccc;border-radius:4px;font-size:0.85rem;width:160px;">
</div>
<button type="submit"
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.35rem .8rem;font-size:0.82rem;cursor:pointer;">
{{ _('Upload') }}
</button>
</form>
</details>
</div>
{% endif %}
</div>
{% endfor %}
@@ -542,45 +587,101 @@
<p style="color:#e74c3c;font-size:0.9rem;">{{ _('CSV file missing — cannot display charts.') }}</p>
{% endif %}
{# --- Re-group panel (owner only) --- #}
{% if is_owner %}
{# --- Visual Group Editor (owner only) --- #}
{% if is_owner and groups_display %}
<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') }}
&#9881; {{ _('Edit groups') }}
</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 style="margin-top:.75rem;">
<div style="font-size:0.8rem;color:#aaa;margin-bottom:.6rem;">
{{ _('Click ✕ between groups to merge them. Click ⊣⊢ inside a group to split it at the midpoint.') }}
</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>
<div id="grp-editor-{{ a.id }}" style="display:flex;align-items:stretch;flex-wrap:wrap;gap:0;user-select:none;"></div>
<form id="regroup-form-{{ a.id }}" method="post"
action="{{ url_for('analyses.regroup', analysis_id=a.id) }}"
style="margin-top:.75rem;display:flex;gap:.6rem;align-items:center;">
<input type="hidden" name="forced_splits" id="forced-splits-{{ a.id }}">
<button type="submit"
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.45rem 1.1rem;font-size:0.88rem;cursor:pointer;">
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.4rem 1rem;font-size:0.85rem;cursor:pointer;">
{{ _('Apply') }}
</button>
</div>
</form>
<button type="button" onclick="grpReset{{ a.id }}()"
style="background:none;color:#666;border:1px solid #ddd;border-radius:4px;padding:.4rem .75rem;font-size:0.85rem;cursor:pointer;">
{{ _('Reset') }}
</button>
</form>
</div>
</details>
<script>
(function() {
var AID = {{ a.id }};
var TOTAL = {{ a.shot_count }};
var INIT_SPLITS = {{ split_positions | tojson }};
var splits = INIT_SPLITS.slice();
var COLORS = ['#dbeafe','#dcfce7','#fef9c3','#fce7f3','#ede9fe','#ccfbf1','#ffedd5'];
var S_SHOTS = {{ _('shots') | tojson }};
var S_MERGE = {{ _('Merge') | tojson }};
var S_SPLIT = {{ _('Split') | tojson }};
function render() {
var el = document.getElementById('grp-editor-' + AID);
el.innerHTML = '';
var bounds = [0].concat(splits).concat([TOTAL]);
for (var i = 0; i < bounds.length - 1; i++) {
var start = bounds[i], end = bounds[i+1], cnt = end - start;
var pct = Math.max(8, Math.round(cnt / TOTAL * 100));
var color = COLORS[i % COLORS.length];
var box = document.createElement('div');
box.style.cssText = 'display:inline-flex;flex-direction:column;align-items:center;justify-content:center;' +
'width:' + pct + '%;min-width:56px;background:' + color + ';border:1px solid #ccc;border-radius:4px;' +
'padding:.4rem .3rem;text-align:center;font-size:0.78rem;box-sizing:border-box;';
box.innerHTML = '<strong style="font-size:.82rem;">' + (i+1) + '</strong>' +
'<span style="color:#555;">' + cnt + ' ' + S_SHOTS + '</span>';
if (cnt >= 2) {
var mid = start + Math.floor(cnt / 2);
var sb = document.createElement('button');
sb.type = 'button'; sb.title = S_SPLIT; sb.textContent = '⊣⊢';
sb.style.cssText = 'margin-top:.3rem;font-size:.7rem;padding:.1rem .3rem;border:1px dashed #888;border-radius:3px;background:rgba(255,255,255,.6);cursor:pointer;';
(function(m){ sb.onclick = function(){ addSplit(m); }; })(mid);
box.appendChild(sb);
}
el.appendChild(box);
if (i < bounds.length - 2) {
var sep = document.createElement('div');
sep.style.cssText = 'display:inline-flex;align-items:center;padding:0 .15rem;';
var mb = document.createElement('button');
mb.type = 'button'; mb.title = S_MERGE; mb.textContent = '✕';
mb.style.cssText = 'border:none;background:none;color:#c0392b;font-size:.9rem;cursor:pointer;padding:.15rem .3rem;';
var capSplit = bounds[i+1];
(function(sp){ mb.onclick = function(){ removeSplit(sp); }; })(capSplit);
sep.appendChild(mb);
el.appendChild(sep);
}
}
}
function addSplit(pos) {
if (splits.indexOf(pos) === -1) { splits.push(pos); splits.sort(function(a,b){return a-b;}); }
render();
}
function removeSplit(pos) {
splits = splits.filter(function(s){ return s !== pos; });
render();
}
window['grpReset' + AID] = function() { splits = INIT_SPLITS.slice(); render(); };
document.getElementById('regroup-form-' + AID).addEventListener('submit', function() {
document.getElementById('forced-splits-' + AID).value = JSON.stringify(splits);
});
render();
})();
</script>
{% endif %}
</div>

View File

@@ -717,3 +717,87 @@ msgstr "Benutzer %(email)s gelöscht."
msgid "Admin"
msgstr "Admin"
msgid "— select —"
msgstr "— auswählen —"
msgid "Prone"
msgstr "Liegend"
msgid "Bench rest"
msgstr "Auflage"
msgid "Standing"
msgstr "Stehend"
msgid "Standing with support"
msgstr "Stehend mit Stütze"
msgid "Kneeling"
msgstr "Kniend"
msgid "Sitting"
msgstr "Sitzend"
msgid "Sitting with support"
msgstr "Sitzend mit Stütze"
msgid "Barricade"
msgstr "Barrikade"
msgid "Rooftop"
msgstr "Dach"
msgid "Variable"
msgstr "Variabel"
msgid "Sunny"
msgstr "Sonnig"
msgid "Partly cloudy"
msgstr "Teilweise bewölkt"
msgid "Overcast"
msgstr "Bedeckt"
msgid "Rain"
msgstr "Regen"
msgid "Wind"
msgstr "Wind"
msgid "Snow"
msgstr "Schnee"
msgid "Fog"
msgstr "Nebel"
msgid "Rifle"
msgstr "Gewehr"
msgid "Handgun"
msgstr "Pistole"
msgid "Other"
msgstr "Sonstiges"
msgid "Edit groups"
msgstr "Gruppen bearbeiten"
msgid "Merge"
msgstr "Zusammenführen"
msgid "Split"
msgstr "Aufteilen"
msgid "Reset"
msgstr "Zurücksetzen"
msgid "Click ✕ between groups to merge them. Click ⊣⊢ inside a group to split it at the midpoint."
msgstr "Klicken Sie auf ✕ zwischen Gruppen zum Zusammenführen. Klicken Sie auf ⊣⊢ in einer Gruppe zum Aufteilen."
msgid "No photo selected."
msgstr "Kein Foto ausgewählt."
msgid "Photo added."
msgstr "Foto hinzugefügt."

View File

@@ -717,3 +717,87 @@ msgstr "Utilisateur %(email)s supprimé."
msgid "Admin"
msgstr "Admin"
msgid "— select —"
msgstr "— choisir —"
msgid "Prone"
msgstr "Couché"
msgid "Bench rest"
msgstr "Banc de tir"
msgid "Standing"
msgstr "Debout"
msgid "Standing with support"
msgstr "Debout avec appui"
msgid "Kneeling"
msgstr "Agenouillé"
msgid "Sitting"
msgstr "Assis"
msgid "Sitting with support"
msgstr "Assis avec appui"
msgid "Barricade"
msgstr "Barricade"
msgid "Rooftop"
msgstr "Toit"
msgid "Variable"
msgstr "Variable"
msgid "Sunny"
msgstr "Ensoleillé"
msgid "Partly cloudy"
msgstr "Partiellement nuageux"
msgid "Overcast"
msgstr "Couvert"
msgid "Rain"
msgstr "Pluie"
msgid "Wind"
msgstr "Vent"
msgid "Snow"
msgstr "Neige"
msgid "Fog"
msgstr "Brouillard"
msgid "Rifle"
msgstr "Carabine"
msgid "Handgun"
msgstr "Pistolet"
msgid "Other"
msgstr "Autre"
msgid "Edit groups"
msgstr "Modifier les groupes"
msgid "Merge"
msgstr "Fusionner"
msgid "Split"
msgstr "Diviser"
msgid "Reset"
msgstr "Réinitialiser"
msgid "Click ✕ between groups to merge them. Click ⊣⊢ inside a group to split it at the midpoint."
msgstr "Cliquez sur ✕ entre les groupes pour les fusionner. Cliquez sur ⊣⊢ dans un groupe pour le diviser au milieu."
msgid "No photo selected."
msgstr "Aucune photo sélectionnée."
msgid "Photo added."
msgstr "Photo ajoutée."