From 572d0cbca94143947baf6fbb1721d13787e88aa6 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Tue, 26 May 2026 12:43:57 +0100 Subject: [PATCH 1/7] Add pyproject.toml and uv-based setup; fix Python 3.12+ warnings and mac launcher --- .gitignore | 4 ++++ README.md | 24 +++++++++++++++++++++--- fstimer.py | 2 ++ fstimer/gui/printfields.py | 2 +- fstimer/timer.py | 2 +- fstimer_mac.command | 3 +-- pyproject.toml | 17 +++++++++++++++++ 7 files changed, 47 insertions(+), 7 deletions(-) create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore index 0d20b64..77d4795 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ *.pyc +__pycache__/ +.venv/ +uv.lock +log.txt diff --git a/README.md b/README.md index 38ef1d5..a544f57 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,28 @@ fstimer ======= -Development for fsTimer, free, open source software for race timing. +Free, open source software for race timing. See fstimer.org for more information. -See fstimer.org for more information and for stable releases. +fsTimer is written in Python 3 and uses GTK 3 via PyGObject. -fsTimer is written in Python3 and uses GTK3+ via PyGObject. +## Setup + +GTK is a native library, so install it through your system package manager first, then use `uv` for the Python side. + +macOS: + + brew install gtk+3 pygobject3 adwaita-icon-theme + uv sync + uv run python fstimer.py + +Debian/Ubuntu: + + sudo apt install libgtk-3-dev libgirepository1.0-dev libcairo2-dev + uv sync + uv run python fstimer.py + +If PyGObject fails to build on macOS, point pkg-config at homebrew: + + PKG_CONFIG_PATH="/opt/homebrew/lib/pkgconfig:/opt/homebrew/opt/libffi/lib/pkgconfig" uv sync Contact admin@fstimer.org if you would like to become a collaborator. diff --git a/fstimer.py b/fstimer.py index e295895..98f183a 100755 --- a/fstimer.py +++ b/fstimer.py @@ -2,6 +2,8 @@ import fstimer.fslogger +import gi +gi.require_version('Gtk', '3.0') import fstimer.timer from gi.repository import Gtk diff --git a/fstimer/gui/printfields.py b/fstimer/gui/printfields.py index 9c4a675..77bde22 100644 --- a/fstimer/gui/printfields.py +++ b/fstimer/gui/printfields.py @@ -198,7 +198,7 @@ def code_edit(self, widget, path, text): # Validate the code try: # First make sure all of the variables are Time or a registration field - vars_ = re.findall("\{[^}]+\}", text) + vars_ = re.findall(r"\{[^}]+\}", text) for var in vars_: name = var[1:-1] if not (name in self.fields or name == 'Time'): diff --git a/fstimer/timer.py b/fstimer/timer.py index 1f38e62..9212a8c 100644 --- a/fstimer/timer.py +++ b/fstimer/timer.py @@ -233,7 +233,7 @@ def print_fields(self, jnk_unused, edit): bad_fields = [] for field, text in self.printfields.items(): if not (field in self.fields or field in ['Time', 'Pace']): - vars_ = re.findall("\{[^}]+\}", text) + vars_ = re.findall(r"\{[^}]+\}", text) for var in vars_: name = var[1:-1] if not (name in self.fields or name == 'Time'): diff --git a/fstimer_mac.command b/fstimer_mac.command index a2e2d10..b6eb5a1 100755 --- a/fstimer_mac.command +++ b/fstimer_mac.command @@ -1,4 +1,3 @@ #!/bin/sh cd "$(dirname "$0")" -nohup /opt/local/bin/python3.5 fstimer.py & -killall Terminal \ No newline at end of file +exec ./.venv/bin/python fstimer.py \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e81e909 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[project] +name = "fstimer" +version = "0.7" +description = "Free, open source software for race timing" +requires-python = ">=3.9" +license = "GPL-3.0-or-later" +dependencies = [ + "PyGObject>=3.50", + "pycairo>=1.26", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["fstimer"] From a325bd182adb09831d144c61a190e335bb42b0dd Mon Sep 17 00:00:00 2001 From: Benjamin Nickolls Date: Thu, 11 Jun 2026 19:27:53 +0100 Subject: [PATCH 2/7] update command --- fstimer_mac.command | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fstimer_mac.command b/fstimer_mac.command index b6eb5a1..2fbb052 100755 --- a/fstimer_mac.command +++ b/fstimer_mac.command @@ -1,3 +1,3 @@ #!/bin/sh cd "$(dirname "$0")" -exec ./.venv/bin/python fstimer.py \ No newline at end of file +exec uv run python fstimer.py \ No newline at end of file From e085c82595be845e7a70bd5c14147823b2490444 Mon Sep 17 00:00:00 2001 From: Benjamin Nickolls Date: Thu, 11 Jun 2026 19:59:00 +0100 Subject: [PATCH 3/7] Autosave timing data on every time/bib entry; remove manual Save button Replaces the manual Save button with continuous autosaving to a fixed {project}_times.json file. Timing data is written after every time stamp or bib number entry, guarded by a _timing_started flag so no file is created until the Start! button is clicked. Simplifies the Done dialog since data is never at risk of being lost. Co-Authored-By: Claude Sonnet 4.6 --- fstimer/gui/timing.py | 55 ++++++++++++++++++++----------------------- 1 file changed, 25 insertions(+), 30 deletions(-) diff --git a/fstimer/gui/timing.py b/fstimer/gui/timing.py index 07bc755..931bab7 100644 --- a/fstimer/gui/timing.py +++ b/fstimer/gui/timing.py @@ -111,6 +111,7 @@ def __init__(self, pytimer, timebtn): tophbox = Gtk.HBox() # our default t0, and the stuff on top for setting/edit t0 self.t0 = 0. + self._timing_started = False btn_t0 = Gtk.Button('Start!') btn_t0.connect('clicked', self.set_t0) # time display @@ -180,16 +181,11 @@ def __init__(self, pytimer, timebtn): edit_vbox.pack_start(btnEDIT, False, False, 0) edit_align = Gtk.Alignment.new(1, 0, 1, 0) edit_align.add(edit_vbox) - #Then the print and save buttons + #Then the print button btnPRINT = Gtk.Button('Printouts') btnPRINT.connect('clicked', self.print_html, pytimer) - btnSAVE = GtkStockButton('save',"Save") - btnSAVE.connect('clicked', self.save_times) - save_vbox = Gtk.VBox(True, 8) - save_vbox.pack_start(btnPRINT, False, False, 0) - save_vbox.pack_start(btnSAVE, False, False, 0) - save_align = Gtk.Alignment.new(1, 1, 1, 0) - save_align.add(save_vbox) + print_align = Gtk.Alignment.new(1, 1, 1, 0) + print_align.add(btnPRINT) #And finally the finish button btnOK = GtkStockButton('close',"Close") btnOK.connect('clicked', self.done_timing) @@ -198,7 +194,7 @@ def __init__(self, pytimer, timebtn): vsubbox = Gtk.VBox(True, 0) vsubbox.pack_start(options_align, True, True, 0) vsubbox.pack_start(edit_align, True, True, 0) - vsubbox.pack_start(save_align, True, True, 0) + vsubbox.pack_start(print_align, True, True, 0) vsubbox.pack_start(done_align, True, True, 0) vspacer = Gtk.Alignment.new(1, 1, 0, 0) vspacer.add(vsubbox) @@ -262,6 +258,8 @@ def set_t0(self, btn): self.t0 = time.time() GLib.timeout_add(100, self.update_clock) #update clock every 100ms btn.set_sensitive(False) + self._timing_started = True + self._autosave_times() def restart_t0(self, jnk_unused): '''Handles click on restart clock button''' @@ -589,30 +587,25 @@ def resume_times(self, jnk_unused, isMerge): error_dialog.destroy() chooser.destroy() - def save_times(self, jnk_unused): - '''Handles click on the Save button - jsonn dump to the already specified filename''' - saveresults = {} - saveresults['rawtimes'] = self.rawtimes - saveresults['timestr'] = self.timestr - saveresults['t0'] = self.t0 - with open(os.path.join(self.path, os.path.basename(self.path)+'_'+self.timestr+'_times.json'), 'w', encoding='utf-8') as fout: + def _autosave_times(self): + '''Silently write current timing state to the times JSON file''' + if not self._timing_started: + return + saveresults = { + 'rawtimes': self.rawtimes, + 'timestr': self.timestr, + 't0': self.t0, + } + with open(os.path.join(self.path, os.path.basename(self.path) + '_times.json'), 'w', encoding='utf-8') as fout: json.dump(saveresults, fout) - md = MsgDialog(self, 'information', ['ok'], 'Saved!', 'Times saved!') - md.run() - md.destroy() def done_timing(self, source): - '''Handles click on the Done button - Gives a dialog before closing.''' - oktime_dialog2 = MsgDialog(self, 'question', ['yes', 'no', 'cancel'], 'Save?', 'Do you want to save before finishing?\nUnsaved data will be lost.') - response2 = oktime_dialog2.run() - oktime_dialog2.destroy() - if response2 == Gtk.ResponseType.CANCEL: - return - elif response2 == Gtk.ResponseType.YES: - self.save_times(None) - self.hide() + '''Handles click on the Done button''' + dialog = MsgDialog(self, 'question', ['yes', 'no'], 'Finish timing?', 'Are you sure you want to finish timing?') + response = dialog.run() + dialog.destroy() + if response == Gtk.ResponseType.YES: + self.hide() def update_racers(self, ID): '''Updates racers_reg and racers_in after arrival of user ID''' @@ -654,6 +647,7 @@ def record_time(self, jnk_unused): except ValueError: pass self.entrybox.set_text('') + self._autosave_times() def new_blank_time(self): '''Record a new time''' @@ -668,6 +662,7 @@ def new_blank_time(self): self.timemodel.set_value(self.timemodel.get_iter(-self.offset-1), 1, t) self.offset += 1 self.entrybox.set_text('') + self._autosave_times() def print_csv(self, pytimer): res = print_times(pytimer, True) # True is to print csv From 570177a1fc2375f3d95cde9b3a74bd6cfb8c99ae Mon Sep 17 00:00:00 2001 From: Benjamin Nickolls Date: Thu, 11 Jun 2026 20:06:14 +0100 Subject: [PATCH 4/7] Auto-load last timing session on window open for crash recovery If a _times.json autosave file exists when the timing window opens, it is loaded silently so the race can continue without manual intervention. Also fixes resume_times() which previously left _timing_started=False, disabling autosave for the rest of a manually resumed session. Co-Authored-By: Claude Sonnet 4.6 --- fstimer/gui/timing.py | 43 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/fstimer/gui/timing.py b/fstimer/gui/timing.py index 931bab7..cf18898 100644 --- a/fstimer/gui/timing.py +++ b/fstimer/gui/timing.py @@ -112,13 +112,13 @@ def __init__(self, pytimer, timebtn): # our default t0, and the stuff on top for setting/edit t0 self.t0 = 0. self._timing_started = False - btn_t0 = Gtk.Button('Start!') - btn_t0.connect('clicked', self.set_t0) + self.btn_t0 = Gtk.Button('Start!') + self.btn_t0.connect('clicked', self.set_t0) # time display self.clocklabel = Gtk.Label() self.clocklabel.modify_font(Pango.FontDescription("sans 20")) self.clocklabel.set_markup(time_format(0)) - tophbox.pack_start(btn_t0, False, False, 10) + tophbox.pack_start(self.btn_t0, False, False, 10) tophbox.pack_start(self.clocklabel, False, False, 10) timevbox1 = Gtk.VBox(False, 8) timevbox1.pack_start(tophbox, False, False, 0) @@ -203,6 +203,7 @@ def __init__(self, pytimer, timebtn): timehbox.pack_start(vspacer, False, False, 0) self.add(timehbox) self.show_all() + self._autoload_times() def print_corrected_time(self, column, renderer, model, itr, data): '''computes a handicap corrected time from en entry in the timing model''' @@ -563,6 +564,8 @@ def resume_times(self, jnk_unused, isMerge): #self.timestr = saveresults['timestr'] #We will _not_ overwrite when resuming. self.t0 = saveresults['t0'] GLib.timeout_add(100, self.update_clock) #start the stopwatch + self._timing_started = True + self.btn_t0.set_sensitive(False) # Recompute how many racers have checked in self.racers_in = [0] * self.numlaps for ID in self.rawtimes['ids']: @@ -599,6 +602,40 @@ def _autosave_times(self): with open(os.path.join(self.path, os.path.basename(self.path) + '_times.json'), 'w', encoding='utf-8') as fout: json.dump(saveresults, fout) + def _autoload_times(self): + '''Silently load autosave file on startup if one exists''' + filepath = os.path.join(self.path, os.path.basename(self.path) + '_times.json') + if not os.path.exists(filepath): + return + try: + with open(filepath, 'r', encoding='utf-8') as fin: + saveresults = json.load(fin) + newrawtimes = saveresults['rawtimes'] + self.rawtimes['ids'] = newrawtimes['ids'] + self.rawtimes['times'] = newrawtimes['times'] + self.t0 = saveresults['t0'] + GLib.timeout_add(100, self.update_clock) + self.racers_in = [0] * self.numlaps + for ID in self.rawtimes['ids']: + self.update_racers(ID) + self.offset = len(self.rawtimes['times']) - len(self.rawtimes['ids']) + self.update_racers_label() + self.timemodel.clear() + if self.offset >= 0: + adj_ids = ['' for i_unused in range(self.offset)] + adj_ids.extend(self.rawtimes['ids']) + adj_times = list(self.rawtimes['times']) + else: + adj_times = ['' for i_unused in range(-self.offset)] + adj_times.extend(self.rawtimes['times']) + adj_ids = list(self.rawtimes['ids']) + for entry in zip(adj_ids, adj_times): + self.timemodel.append(list(entry)) + self._timing_started = True + self.btn_t0.set_sensitive(False) + except (IOError, ValueError, KeyError, TypeError): + pass + def done_timing(self, source): '''Handles click on the Done button''' dialog = MsgDialog(self, 'question', ['yes', 'no'], 'Finish timing?', 'Are you sure you want to finish timing?') From 303f82a07d5d2483b01f0ac076fa39506487257a Mon Sep 17 00:00:00 2001 From: Benjamin Nickolls Date: Thu, 11 Jun 2026 20:16:26 +0100 Subject: [PATCH 5/7] Autosave after all timing edits (drop, single edit, block edit) Adds _autosave_times() to the four edit paths that were missing it: timing_rm_ID, timing_rm_time, editsingletimedone, editblocktimedone. Drop operations save inside the confirmed branch only. Co-Authored-By: Claude Sonnet 4.6 --- fstimer/gui/timing.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/fstimer/gui/timing.py b/fstimer/gui/timing.py index cf18898..38f866f 100644 --- a/fstimer/gui/timing.py +++ b/fstimer/gui/timing.py @@ -417,6 +417,7 @@ def editsingletimedone(self, treeiter, new_id, new_time): self.lapcounter = defaultdict(int) self.lapcounter.update(Counter(self.rawtimes['ids'])) self.winedittime.hide() + self._autosave_times() def editblocktimedone(self, pathlist, operation, timestr): '''Handled result of the editing of a block of times @@ -439,6 +440,7 @@ def editblocktimedone(self, pathlist, operation, timestr): # This will happen for instance if the gtkpath has a blank time pass self.wineditblocktime.hide() + self._autosave_times() def timing_rm_ID(self, jnk_unused): '''Handles click on the Drop ID button @@ -483,6 +485,7 @@ def timing_rm_ID(self, jnk_unused): # There is a blank row at the top which should be removed. treeiter = self.timemodel.get_iter((rowcounter,)) self.timemodel.remove(treeiter) + self._autosave_times() def timing_rm_time(self, jnk_unused): '''Handles click on Drop time comment @@ -527,6 +530,7 @@ def timing_rm_time(self, jnk_unused): # there is a blank row at the top which should be removed. treeiter = self.timemodel.get_iter((rowcounter,)) self.timemodel.remove(treeiter) + self._autosave_times() def resume_times(self, jnk_unused, isMerge): '''Handles click on Resume button''' From 4ceaf677d9d44d2ad37a1c94bcf0cd83e4f51a05 Mon Sep 17 00:00:00 2001 From: Benjamin Nickolls Date: Thu, 11 Jun 2026 20:28:43 +0100 Subject: [PATCH 6/7] Auto-generate CSV and HTML results after every ID allocation and edit Stores pytimer on TimingWin and adds _autoprint() which silently calls print_times() for both CSV and HTML on every record_time(), drop, and edit operation. Files are overwritten in place each time using the session's fixed timestr, so results stay current throughout the race. Co-Authored-By: Claude Sonnet 4.6 --- fstimer/gui/timing.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/fstimer/gui/timing.py b/fstimer/gui/timing.py index 38f866f..b8cafa8 100644 --- a/fstimer/gui/timing.py +++ b/fstimer/gui/timing.py @@ -47,6 +47,7 @@ class TimingWin(Gtk.Window): def __init__(self, pytimer, timebtn): '''Builds and display the compilation error window''' super(TimingWin, self).__init__(Gtk.WindowType.TOPLEVEL) + self.pytimer = pytimer self.path = pytimer.path self.projecttype = pytimer.projecttype self.fields = pytimer.fields @@ -418,6 +419,7 @@ def editsingletimedone(self, treeiter, new_id, new_time): self.lapcounter.update(Counter(self.rawtimes['ids'])) self.winedittime.hide() self._autosave_times() + self._autoprint() def editblocktimedone(self, pathlist, operation, timestr): '''Handled result of the editing of a block of times @@ -441,6 +443,7 @@ def editblocktimedone(self, pathlist, operation, timestr): pass self.wineditblocktime.hide() self._autosave_times() + self._autoprint() def timing_rm_ID(self, jnk_unused): '''Handles click on the Drop ID button @@ -486,6 +489,7 @@ def timing_rm_ID(self, jnk_unused): treeiter = self.timemodel.get_iter((rowcounter,)) self.timemodel.remove(treeiter) self._autosave_times() + self._autoprint() def timing_rm_time(self, jnk_unused): '''Handles click on Drop time comment @@ -531,6 +535,7 @@ def timing_rm_time(self, jnk_unused): treeiter = self.timemodel.get_iter((rowcounter,)) self.timemodel.remove(treeiter) self._autosave_times() + self._autoprint() def resume_times(self, jnk_unused, isMerge): '''Handles click on Resume button''' @@ -606,6 +611,16 @@ def _autosave_times(self): with open(os.path.join(self.path, os.path.basename(self.path) + '_times.json'), 'w', encoding='utf-8') as fout: json.dump(saveresults, fout) + def _autoprint(self): + '''Silently generate CSV and HTML results files''' + if not self._timing_started: + return + try: + print_times(self.pytimer, True) + print_times(self.pytimer, False) + except Exception: + pass + def _autoload_times(self): '''Silently load autosave file on startup if one exists''' filepath = os.path.join(self.path, os.path.basename(self.path) + '_times.json') @@ -689,6 +704,7 @@ def record_time(self, jnk_unused): pass self.entrybox.set_text('') self._autosave_times() + self._autoprint() def new_blank_time(self): '''Record a new time''' From 0eed62397abe0bb89eeca58c0bebda96b6ed7f47 Mon Sep 17 00:00:00 2001 From: Benjamin Nickolls Date: Thu, 11 Jun 2026 20:29:51 +0100 Subject: [PATCH 7/7] add CLAUDE.md --- CLAUDE.md | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..14e885a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,79 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## About + +fsTimer is a GTK 3 / Python 3 desktop application for race timing: managing registrations, assigning bib numbers, recording finish times, and generating results in CSV/HTML formats. + +## Setup & Running + +```bash +# macOS: install GTK dependencies first +brew install gtk+3 pygobject3 adwaita-icon-theme + +# Install Python dependencies +uv sync + +# Run the app +uv run python fstimer.py +``` + +If PyGObject build fails on macOS: +```bash +PKG_CONFIG_PATH="/opt/homebrew/lib/pkgconfig:/opt/homebrew/opt/libffi/lib/pkgconfig" uv sync +``` + +There is no test suite and no linter configured. Testing is manual/interactive. + +## Architecture + +### Entry point & orchestrator + +`fstimer.py` initializes `fstimer.timer.PyTimer` and starts the GTK main loop. `PyTimer` (522 lines) is the central state machine — it holds all project state and manages every window transition by passing callbacks between windows. + +Key state on `PyTimer`: +- `self.path` — project directory (absolute) +- `self.fields` / `self.fieldsdic` — registration field names and metadata +- `self.divisions` / `self.rankings` — race division definitions and result ranking criteria +- `self.projecttype` — `'standard'` or `'handicap'` +- `self.numlaps` / `self.variablelaps` — single-lap vs. multi-lap race modes +- `self.timing` — `Dict[racer_id][lap] → time` (defaultdict, populated live during timing) +- `self.rawtimes` — `{'times': [...], 'ids': [...]}` raw input buffer +- `self.timedict` — `Dict[racer_id] → registration info` (compiled before timing starts) + +### Module layout + +``` +fstimer/ + timer.py # PyTimer orchestrator + time_ops.py # time_format(), time_parse(), time_diff(), time_sum() + fslogger.py # stderr → log.txt + gui/ # One file per window/dialog (~25 files) + printer/ # Output generation + formatter.py # Main results logic — processes timing + registration → CSV/HTML + printcsv.py / printhtml.py / printcsvlaps.py / printhtmllaps.py +``` + +### Race-day workflow + +1. **Project setup** (one-time): define fields → divisions → print fields → rankings → saved as `{project}.reg` (JSON) +2. **Registration**: import pre-reg CSV or enter manually → one JSON file per batch +3. **Compile**: merge JSON batches, resolve duplicate IDs → `{project}_registration_compiled.json` and `{project}_timing_dict.json` +4. **Timing**: `timing.py` records raw times in real-time; IDs are assigned post-finish or during timing +5. **Results**: `printer/formatter.py` joins timing data with registration to produce `_alltimes` and `_divtimes` outputs + +### Data files + +| Extension | Purpose | +|-----------|---------| +| `{name}.reg` | Project config (JSON) | +| `_registration_*.json` | Individual registration batches | +| `_registration_compiled.json` | Merged, deduped registrations | +| `_timing_dict.json` | ID → racer info (pre-timing snapshot) | +| `_alltimes.csv/html` | Full results | +| `_divtimes.csv/html` | Per-division results | + +### GUI pattern + +Windows are shown/hidden (not destroyed) and pass callbacks to `PyTimer` for state transitions. Ignored GTK callback parameters are named `jnk_unused` by convention. The `fstimer_demo/` directory contains sample project data for manual testing.