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/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. 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/gui/timing.py b/fstimer/gui/timing.py index 07bc755..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 @@ -111,13 +112,14 @@ def __init__(self, pytimer, timebtn): tophbox = Gtk.HBox() # our default t0, and the stuff on top for setting/edit t0 self.t0 = 0. - btn_t0 = Gtk.Button('Start!') - btn_t0.connect('clicked', self.set_t0) + self._timing_started = False + 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) @@ -180,16 +182,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 +195,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) @@ -207,6 +204,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''' @@ -262,6 +260,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''' @@ -418,6 +418,8 @@ 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() + self._autoprint() def editblocktimedone(self, pathlist, operation, timestr): '''Handled result of the editing of a block of times @@ -440,6 +442,8 @@ 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() + self._autoprint() def timing_rm_ID(self, jnk_unused): '''Handles click on the Drop ID button @@ -484,6 +488,8 @@ 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() + self._autoprint() def timing_rm_time(self, jnk_unused): '''Handles click on Drop time comment @@ -528,6 +534,8 @@ 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() + self._autoprint() def resume_times(self, jnk_unused, isMerge): '''Handles click on Resume button''' @@ -565,6 +573,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']: @@ -589,30 +599,69 @@ 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: + 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') + if not os.path.exists(filepath): return - elif response2 == Gtk.ResponseType.YES: - self.save_times(None) - self.hide() + 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?') + 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 +703,8 @@ def record_time(self, jnk_unused): except ValueError: pass self.entrybox.set_text('') + self._autosave_times() + self._autoprint() def new_blank_time(self): '''Record a new time''' @@ -668,6 +719,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 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..2fbb052 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 uv run 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"]