Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
*.pyc
__pycache__/
.venv/
uv.lock
log.txt
79 changes: 79 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 21 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions fstimer.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import fstimer.fslogger

import gi
gi.require_version('Gtk', '3.0')
import fstimer.timer
from gi.repository import Gtk

Expand Down
2 changes: 1 addition & 1 deletion fstimer/gui/printfields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'):
Expand Down
118 changes: 85 additions & 33 deletions fstimer/gui/timing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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'''
Expand Down Expand Up @@ -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'''
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'''
Expand Down Expand Up @@ -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']:
Expand All @@ -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'''
Expand Down Expand Up @@ -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'''
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion fstimer/timer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'):
Expand Down
3 changes: 1 addition & 2 deletions fstimer_mac.command
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
#!/bin/sh
cd "$(dirname "$0")"
nohup /opt/local/bin/python3.5 fstimer.py &
killall Terminal
exec uv run python fstimer.py
17 changes: 17 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"]