Repository: espruino/BangleApps
Pull request #3851: Umpire Ball Counter app
Author: login: AndySmee
Created: 2025-05-13T21:06:52Z | Merged: 2025-05-14T08:08:28Z

I think I'm ready to share this app with the community


Diff:
diff --git a/apps/umpire/20250511_210249-COLLAGE.jpg b/apps/umpire/20250511_210249-COLLAGE.jpg
new file mode 100644
index 0000000000..91622ad44a
Binary files /dev/null and b/apps/umpire/20250511_210249-COLLAGE.jpg differ
diff --git a/apps/umpire/ChangeLog b/apps/umpire/ChangeLog
new file mode 100644
index 0000000000..bffacca093
--- /dev/null
+++ b/apps/umpire/ChangeLog
@@ -0,0 +1 @@
+0.01: First release with heart rate monitoring
diff --git a/apps/umpire/README.md b/apps/umpire/README.md
new file mode 100644
index 0000000000..9e7ed73183
--- /dev/null
+++ b/apps/umpire/README.md
@@ -0,0 +1,94 @@
+# Umpire Ball Counter
+*Cricket umpire ball counter and match event logger for Bangle.js 2 hackable smart watch*
+
+![Bangle.js2 smart watch in a collage of screenshots](20250511_210249-COLLAGE.jpg)
+
+## Background
+There are a few ways to keep track of balls when umpiring a cricket match. These vary from the rudimentary - having some stones in your pockets and transferring them from one side to the other - to the "modern" umpire's clicker. The latter allows you to track fairly delivered balls, overs completed and wickets fallen.
+
+In addition, the umpire needs to know the time and to record events like when "Play" was called or when a fielder left the field. These events are written down on paper.
+
+The umpire also needs to assess whether either team is timewasting and whether a new batter has made it out to the crease on time, as required by the Laws.
+
+This app is designed to replace the umpire clicker, and keep track of events for analysis and reporting after each match.
+
+## Objectives ##
+
+The app has three objectives:
+1. Count how many balls and wickets have been logged, show the current over number and the time.
+2. Log these and other events with a timestamp and a duration since the last event.
+3. Allow the data to be viewed on the watch and on a BLE-paired mobile device as structured data.
+
+## Modules ##
+
+The app is split into three screens shown to the umpire:
+1. Main Menu
+2. In-play Screen
+3. Log viewer
+
+There is an additional module to allow settings to be changed via the watch Settings menu; the number of balls in the over and the number of overs in the innings are configurable, so that 15 eight-ball overs, T20, 40-50 overs and The Hundred can all be supported.
+
+A further module of the app is the web page used to view the log file in the app loader. The file can be deleted or a copy downloaded.
+
+## Interaction ##
+
+Bangle.js 2 app interactions are governed by the Bangle and Espruino (E) libraries. This app uses the following patterns:
+
+### Swipe and Button ###
+
+The in-play screen detects swipe and button events using Bangle.setUI.
+
+When in-play, use of the button increments the (fairly delivered) ball count. The app does not suppress the screen lock so by default it requires the umpire to press once to unlock the watch and a second press to log the ball. Bangle.buzz is used to give positive feedback when the app takes action so that the screen does not need to be looked at.
+
+After logging the third-to-last ball in the over, the app buzzes twice. This is typically when the umpire needs to check the balls remaining with the other umpire, in case they have not logged every fair delivery.
+
+After the second-to-last ball the app gives one long buzz to remind the umpire that the next ball will close the over (if fairly delivered).
+
+Logging the final ball triggers the change of over. 
+
+When the last over of the innings is complete the screen shows "END" but allows play to continue to be logged until the new innings is triggered.
+
+Whilst the in-play screen is displayed swiping will cause the following actions to occur:
+- **Swipe Up** performs the same action as the Button press, incrementing the ball count.
+- **Swipe Down** decrements the ball count and logs the action as a "Correction".
+- **Swipe Right** shows the log viewer. 
+- **Swipe Left** shows the main menu.
+
+### Confirmation Prompts ###
+
+When choosing "Wicket" or "Recall" from the main menu, E.showPrompt is used to ask for positive confirmation before incrementing or decrementing the wickets, respectively. Those functions also increment or decrement the ball count.
+
+### Scrollers ###
+
+The main menu and log viewer both use E.showScroller to display a scrollable list of tappable items. This is in preference to E.showMenu which has very small touch targets.
+
+Whenever a scroller is shown, the in-play swipe interactions are switched off.
+
+Tapping on the log viewer returns the umpire to the in-play screen.
+
+### Twist to refresh ###
+
+The app uses Bangle.twist to detect the umpire turning their wrist to view the screen and, when the in-play screen is active, will refresh the current time and elapsed time since the last ball.
+
+## Timing ##
+
+The app calculates three durations:
+1. **Ball to ball** - When logging a ball the elapsed time since the last logged ball.
+2. **Overs** - When logging the last ball of the over, the elapsed time since the last ball of the previous over was logged (or when "Play" was initially called).
+3. **Lost Time** - When logging the next ball after a call of "Time" the elapsed time since the call of "Time".
+
+## Other Metadata Logging ##
+
+The app now includes logging of other sensor data from the watch. the following items have been added to the log function:
+
+1. **Steps** - When the app is launched it notes the current step count and then logs the number of steps the umpire has taken on every ball.
+2. **Battery Level** - In order to assess whether the heart rate monitor is running the battery down, the battery percentage is logged on every ball.
+3. **Heart Rate Monitoring** - The main menu includes an option to "Start HRM" and then the heart rate is logged on every ball.
+
+N.B. The HRM takes about 5 seconds to start measuring the umpire's heart rate. 
+
+Once switched on, the HRM has two additional features:
+
+1. **Battery Saving** - After the Call of Time the HRM is powered down until (Play is called and) the next ball is logged.
+
+2. **Auto-logging** - The app Settings menu has an additional item which is a heartbeat rate over which the app should automatically log an event. Once the umpire's heart rate goes over the limit for 10 consecutive seconds an event will be logged and the in-play screen will show the heart rate on every refresh. Until the heart rate falls below the threshold no further heart events will be logged. A further event will not be triggered until the limit is exceeded again for 20 consecutive seconds. The HRM can be stopped from the main menu during the match.
diff --git a/apps/umpire/app-icon.js b/apps/umpire/app-icon.js
new file mode 100644
index 0000000000..f54effcad4
--- /dev/null
+++ b/apps/umpire/app-icon.js
@@ -0,0 +1 @@
+require("heatshrink").decompress(atob("mEw4UA///yn+hW803RBwUP/gUDlWAAoc//4FD1WgAoYiB4AEBgWq1QLD/1vBYUK0uqEgf9r4LClWVtQkDC4mqqtWBYYvBFwWpqtV1BHGFwILBHgcP+AuDBYNqPwwuBBZAuDEYgACFwdWHYaMDFwVaQgguGTggu/F34uxewguFEYguGHYYuHgEDKYIuHgEP9WVFw4LC0ouHgE//2qFw8A///1QuHgf//kqFxH/4EKFxH/BI4uC/gKIFwPwBZAuCBZAuWBYIuJEYIuJHYIKJgA"))
diff --git a/apps/umpire/app.js b/apps/umpire/app.js
new file mode 100644
index 0000000000..e029329736
--- /dev/null
+++ b/apps/umpire/app.js
@@ -0,0 +1,432 @@
+// settings and environment 
+var settings = Object.assign({
+  // default values
+  ballsPerOver: 6,
+  oversPerInnings: 40,
+  heartRateLimit: 130
+}, require('Storage').readJSON("umpire.json", true) || {});
+const BALLS_PER_OVER = settings.ballsPerOver;
+const OVERS_PER_INNINGS = settings.oversPerInnings;
+const HEART_RATE_LIMIT = settings.heartRateLimit;
+delete settings;
+const TIMEZONE_OFFSET_HOURS = (new Date()).getTimezoneOffset() / 60;
+const STEP_COUNT_OFFSET = Bangle.getStepCount();
+const BALL_TO_COME_CHAR = '-';
+const BALL_FACED_CHAR = '=';
+
+// globals
+var processing = true; //debounce to inhibit twist events
+var wickets = 0;
+var counter = 0;
+var over = 0;
+var ballTimes = [];
+var overTimes = [];
+var timeTimes = [];
+var log = [];
+var timeCalled = false;
+var batteryPercents = [];
+var battery = 0;
+var heartRate = '';
+var heartRateEventSeconds = 0;
+var HRM = false;
+
+function toggleHRM() {
+  if(HRM) {
+    Bangle.setHRMPower(0);
+    HRM = false;
+    heartRateEventSeconds = 0;
+    heartRate = '';
+  } else {
+    Bangle.setHRMPower(1);
+    HRM = true;
+  }
+}
+
+function getBattery() {
+  // calculate last 10 moving average %
+  batteryPercents.push(E.getBattery());
+  if(batteryPercents.length > 10) batteryPercents.shift();
+  return Math.round(batteryPercents.reduce((avg,e,i,arr)=>avg+e/arr.length,0));
+}
+
+// process heart rate monitor event 
+// each second (approx.)
+function updateHeartRate(h) {
+  heartRate = h.bpm || 0;
+  if(heartRate >= HEART_RATE_LIMIT) {
+    heartRateEventSeconds++;
+    if(heartRateEventSeconds==10) 
+      addLog((new Date()), over, counter, 
+        "Heart Rate", ">" + HEART_RATE_LIMIT);
+  }
+  if(heartRateEventSeconds > 10 
+     && heartRate < HEART_RATE_LIMIT) 
+      heartRateEventSeconds = -10;
+}
+
+// write events to storage (csv, persistent) 
+// and memory (can be truncated while running)
+function addLog(timeSig, over, ball, matchEvent, metaData) {
+  var steps = Bangle.getStepCount() - STEP_COUNT_OFFSET;
+  // write to storage
+  var csv = [
+    formatTimeOfDay(timeSig),
+    over-1, ball, 
+    matchEvent, metaData, 
+    steps, battery, heartRate
+  ];
+  file.write(csv.join(",")+"\n");
+  // write to memory
+  log.unshift({ // in rev. chrono. order
+    time: formatTimeOfDay(timeSig),
+    over: over-1,
+    ball: ball,
+    matchEvent: matchEvent,
+    metaData: metaData,
+    steps: steps,
+    battery: battery,
+    heartRate: heartRate
+  });
+}
+
+// display log from memory (not csv)
+function showLog() {
+  processing = true;
+  Bangle.setUI();
+  return E.showScroller({
+    h: 50, c: log.length,
+    draw: (idx, r) => {
+      g.setBgColor((idx&1)?"#000":"#112").clearRect(r.x,r.y,r.x+r.w-1,r.y+r.h-1);
+      if(log[idx].matchEvent==/*LANG*/"Over Duration"
+        || log[idx].matchEvent==/*LANG*/"Innings Duration") {
+        g.setFont("Vector", 22).drawString(
+          log[idx].matchEvent,r.x+6,r.y+2);
+      } else {
+        g.setFont("Vector", 22).drawString(
+          log[idx].over + "." +
+          log[idx].ball + " " +
+          log[idx].matchEvent,r.x+6,r.y+2);
+      }
+      g.setFont("Vector", 18).drawString(
+        log[idx].time + " " +
+        log[idx].metaData + " " +
+        log[idx].heartRate,r.x+6,r.y+27);
+    },
+    select: (idx) => {
+      resumeGame();
+    }
+  });
+}
+
+// format date (diff) as duration
+function formatDuration(timeDate) { 
+  return (timeDate.getHours() + TIMEZONE_OFFSET_HOURS) + ":" 
+    + timeDate.getMinutes().toString().padStart(2, "0") + ":" 
+    + timeDate.getSeconds().toString().padStart(2, "0") + "";
+}
+
+// format date as clock
+function formatTimeOfDay(timeSig) { 
+  return timeSig.getHours() + ":" 
+    + timeSig.getMinutes().toString().padStart(2, "0");
+}
+
+// main ball counter logic
+// and in-play screen
+function countDown(dir) {
+  processing = true;
+  battery = getBattery(); // refresh battery
+  counter += dir;
+  // suppress correction on first ball of innings
+  if(over==1 && counter<0) {
+    counter=0;
+    processing = false;
+    return;
+  }
+  // Suppress dir when play after time
+  if(timeCalled)
+    counter -= dir;
+  // Correction to last ball of over
+  if(counter<0) {
+    counter = BALLS_PER_OVER -1;
+    over -= 1;
+    // use end of over time as last ball time
+    ballTimes.push(overTimes.pop());
+  }
+  // create timestamp for log
+  var timeSig = new Date();
+  // calculate elapsed since last ball
+  var lastBallTime = timeSig.getTime();
+  if(ballTimes.length>0) {
+    lastBallTime = ballTimes[ballTimes.length - 1];
+  } else if(overTimes.length>0) {
+    lastBallTime = overTimes[overTimes.length - 1];
+  }
+  var deadDuration = new Date(
+    timeSig.getTime() - lastBallTime);
+  // process new (dead) ball
+  if(dir!=0) {
+    // call play after time?
+    if(timeCalled) {
+      timeCalled = false;
+      // resume heart rate monitoring
+      if(HRM) Bangle.setHRMPower(1);
+      // calculate time lost and log it
+      var lastTimeTime = timeTimes[timeTimes.length - 1];
+      var timeDuration = new Date(
+        timeSig.getTime() - lastTimeTime);
+      addLog(timeSig, over, counter, 
+        "Play", /*LANG*/"Lost:" + formatDuration(timeDuration));    
+    } else {
+      if(counter>0) // reset elapsed time
+        ballTimes.push(timeSig.getTime());
+      Bangle.setLCDPower(1); //TODO need any more?
+    
+      if(dir>0) { // fairly delivered ball
+        addLog(timeSig, over, counter, 
+          "Ball", formatDuration(deadDuration));
+      } else { // +1 ball still to come
+        addLog(timeSig, over, counter, 
+          /*LANG*/"Correction", formatDuration(deadDuration));
+      }
+    }
+    // give haptic feedback
+    if(counter == BALLS_PER_OVER - 2) {
+      // buzz twice "2 to come"
+      Bangle.buzz(400).then(()=>{
+        return new Promise(
+          resolve=>setTimeout(resolve,500));
+      }).then(()=>{
+        return Bangle.buzz(500);
+      })
+    } else if(counter == BALLS_PER_OVER - 1) {
+      // long buzz "1 to come"
+      Bangle.buzz(800);
+    } else {
+      // otherwise short buzz
+      Bangle.buzz()
+    }
+    // Process end of over
+    if (counter == BALLS_PER_OVER) {
+      // calculate match time
+      var matchDuration = new Date(
+        timeSig.getTime() - overTimes[0]);
+      var matchMinutesString = formatDuration(matchDuration);
+      // calculate over time
+      var overDuration = new Date(
+        timeSig.getTime() - overTimes[overTimes.length - 1]);
+      var overMinutesString = formatDuration(overDuration);  
+      // log end of over
+      addLog(timeSig, over + 1, 0, 
+        /*LANG*/"Over Duration", overMinutesString);
+      addLog(timeSig, over + 1, 0, 
+        /*LANG*/"Innings Duration", matchMinutesString);
+      overTimes.push(timeSig.getTime());
+      // start new over
+      over += 1;
+      counter = 0; 
+      ballTimes = [];
+    }
+  }
+  // refresh in-play screen
+  g.clear(1);
+  // draw wickets fallen (top-right)
+  g.setFontAlign(1,0);
+  g.setFont("Vector",26).
+   drawString(wickets, 162, 14, true);
+  g.setFont("Vector",12).
+   drawString('\¦\¦\¦', 173, 15, true);
+  // draw battery and heart rate (top-left)
+  g.setFontAlign(-1,0);
+  var heartRateString = 'HR:' + heartRate;
+  if(heartRateEventSeconds <= 0) heartRateString = '';
+  g.setFont("Vector",16).
+    drawString(battery + '% ' + heartRateString, 5, 11, true);
+  // draw clock (upper-centre)
+  g.setFontAlign(0,0);
+  g.setFont("Vector",48).
+    drawString(formatTimeOfDay(timeSig), 93, 55, true);
+  // draw over.ball (centre)
+  var ballString = (over-1) + "." + counter;
+  if(over > OVERS_PER_INNINGS) 
+    ballString = 'END';
+  g.setFont("Vector",80).
+    drawString(ballString, 93, 120, true);
+  // draw ball graph and elapsed time
+  var ballGraph = 
+    BALL_FACED_CHAR.repeat(counter) 
+    + BALL_TO_COME_CHAR.repeat(BALLS_PER_OVER - counter);
+  if(timeCalled) ballGraph = '-TIME-';
+  g.setFont("Vector",18).drawString(
+    ballGraph + ' ' + formatDuration(deadDuration), 93, 166, true);
+  // return to wait for next input
+  processing = false;
+}
+
+function resumeGame(play) {
+  processing = true;
+  Bangle.buzz();
+  Bangle.setUI({
+      mode: "custom",
+      swipe: (directionLR, directionUD)=>{
+        if (directionLR==-1) { 
+          processing = true;
+          showMainMenu();
+        } else if (directionLR==1) { 
+          processing = true;
+          showLog();
+        } else if (directionUD==-1) { 
+          processing = true;
+          countDown(1);
+        } else {
+          processing = true;
+          countDown(-1);
+        }
+      },
+      btn: ()=>{
+        processing = true;
+        countDown(1);
+      }
+    });
+  if(over==0) { // at start of innings
+    over += 1; // N.B. 1-based overs in code
+    counter = 0; // balls
+    ballTimes = [];
+    // set an inital time for comparison  
+    var timeSig = new Date();
+    overTimes.push(timeSig.getTime());
+    addLog(timeSig, over, counter, 
+      "Play", "");        
+  }
+  // load in-play screen
+  countDown(play? -1: 0);
+}
+
+function incrementWickets(inc) {
+  processing = true;
+  E.showPrompt(/*LANG*/"Amend wickets by " + inc + "?").
+    then(function(confirmed) {
+    if (confirmed) {
+      Bangle.buzz();
+      wickets += inc;
+      var timeSig = new Date();
+      if(inc>0) {
+        countDown(1);
+        addLog(timeSig, over, counter, 
+          "Wicket", "Wickets: " + wickets);
+      } else {
+        addLog(timeSig, over, counter, 
+          /*LANG*/"Recall Batter", "Wickets: " + wickets);
+      }
+      resumeGame();
+    } else {
+      E.showPrompt();
+      showMainMenu();
+    }
+  });
+}
+
+function showMainMenu() {
+  processing = true;
+  Bangle.setUI();
+  var scrollMenuItems = [];
+  // add menu items
+  if(over>0)
+    scrollMenuItems.push("« Back");
+  if(over==0 || timeCalled) 
+    scrollMenuItems.push("Call Play");
+  if(over>0 && !timeCalled) {
+    scrollMenuItems.push("Wicket");
+    if(wickets>0) 
+      scrollMenuItems.push(/*LANG*/"Recall Batter");
+    scrollMenuItems.push("Call Time");
+    scrollMenuItems.push("New Innings");
+    if(!HRM) 
+      scrollMenuItems.push("Start HRM");
+  }
+  if(HRM) scrollMenuItems.push("Stop HRM");
+  // show menu
+  return E.showScroller({
+    h: 80, c: scrollMenuItems.length,
+    draw: (idx, r) => {
+      g.setBgColor((idx&1)?"#000":"#121").clearRect(r.x,r.y,r.x+r.w-1,r.y+r.h-1);
+      g.setFont("Vector", 30).drawString(scrollMenuItems[idx],r.x+10,r.y+28);
+    },
+    select: (idx) => {
+      if(scrollMenuItems[idx]=="Call Time") {
+        timeCalled = true;
+        // power down HRM until play
+        Bangle.setHRMPower(0);
+        heartRateEventSeconds = 0;
+        var timeSig = new Date();
+        timeTimes.push(timeSig.getTime());
+        addLog(timeSig, over, counter, 
+               "Time", (HRM? "HRM Paused" : ""));        
+        resumeGame();
+      }
+      if(scrollMenuItems[idx]=="Call Play") 
+        resumeGame(timeCalled);
+      if(scrollMenuItems[idx]=="« Back") 
+        resumeGame();      
+      if(scrollMenuItems[idx]=="Wicket") 
+        incrementWickets(1);
+      if(scrollMenuItems[idx]==/*LANG*/"Recall Batter") 
+        incrementWickets(-1);
+      if(scrollMenuItems[idx]=="New Innings") 
+        newInnings();
+      if(scrollMenuItems[idx]=="Start HRM"
+        || scrollMenuItems[idx]=="Stop HRM") {
+        toggleHRM();
+        resumeGame();
+      }
+    }
+  });
+}
+
+function newInnings() {
+  var timeSig = new Date();
+  if(over!=0) { // new innings
+    E.showPrompt(/*LANG*/"Start next innings?").
+      then(function(confirmed) {
+      if (confirmed) {
+        Bangle.buzz();
+        processing = true; //debounce to inhibit twist events
+        wickets = 0;
+        counter = 0;
+        over = 0;
+        ballTimes = [];
+        overTimes = [];
+        timeTimes = [];
+        log = [];
+        timeCalled = false;
+        addLog(timeSig, OVERS_PER_INNINGS + 1, BALLS_PER_OVER, 
+          "New Innings", require("locale").date(new Date(), 1));
+        resumeGame();
+      } else {
+        E.showPrompt();
+        showMainMenu();
+      }
+    });
+  } else { // resume innings or start app
+    addLog(timeSig, OVERS_PER_INNINGS + 1, BALLS_PER_OVER, 
+      "New Innings", require("locale").date(new Date(), 1));
+  }
+}
+// initialise file in storage
+var file = require("Storage").open("matchlog.csv","a");
+// save state on exit TODO WIP
+E.on("kill", function() {
+  console.log("Umpire app closed at " + (over-1) + "." + counter);
+});
+// set up twist refresh once only 
+Bangle.on('twist', function() { 
+  if(!processing) {
+    processing = true; // debounce
+    countDown(0);
+  }
+});
+// set up HRM listener once only
+Bangle.on('HRM', function(h) {
+  updateHeartRate(h)});
+newInnings(); // prepare 1st innings
+showMainMenu(); // ready to play
diff --git a/apps/umpire/app.png b/apps/umpire/app.png
new file mode 100644
index 0000000000..e9866f59d2
Binary files /dev/null and b/apps/umpire/app.png differ
diff --git a/apps/umpire/interface.html b/apps/umpire/interface.html
new file mode 100644
index 0000000000..34dc5307d7
--- /dev/null
+++ b/apps/umpire/interface.html
@@ -0,0 +1,76 @@
+<html>
+  <head>
+    <link rel="stylesheet" href="../../css/spectre.min.css">
+  </head>
+  <body>
+    <div id="data"></div>
+    <button class="btn btn-default" id="btnSave">Download</button>
+    <button class="btn btn-default" id="btnDelete">Delete</button>
+
+    <script src="../../core/lib/interface.js"></script>
+    <script>
+var dataElement = document.getElementById("data");
+var csvData = "";
+
+function getData() {
+  // show loading window
+  Util.showModal("Loading...");
+  // get the data
+  dataElement.innerHTML = "";
+  Util.readStorageFile(`matchlog.csv`,data=>{
+    csvData = data.trim();
+    // remove window
+    Util.hideModal();
+    // If no data, report it and exit
+    if (data.length==0) {
+      dataElement.innerHTML = "<b>No data found</b>";
+      return;
+    }
+// Otherwise parse the data and output it as a table
+    dataElement.innerHTML = `<table style="border: 1px solid #cccccc">
+    <tr>
+      <th>Time</th>
+      <th>Over</th>
+      <th>Ball</th>
+      <th>Event</th>
+      <th>Metadata</th>  
+      <th>Steps</th>
+      <th>Battery %</th>
+      <th>Heart Rate</th>
+    </tr>`+data.trim().split("\n").map(l=>{
+      l = l.split(",");
+      return `<tr>
+      <td>${l[0]}</td>
+      <td align="center">${l[1]}</td>
+      <td align="center">${l[2]}</td>
+      <td>${l[3]}</td>
+      <td>${l[4]}</td>
+      <td>${l[5]}</td>
+      <td>${l[6]}</td>
+      <td>${l[7]}</td>
+      </tr>`
+    }).join("\n")+"</table>";
+  });
+}
+
+// You can call a utility function to save the data
+document.getElementById("btnSave").addEventListener("click", function() {
+  // TODO column headings in output
+  Util.saveCSV("matchdata", csvData);
+});
+// Or you can also delete the file
+document.getElementById("btnDelete").addEventListener("click", function() {
+  Util.showModal("Deleting...");
+  Util.eraseStorageFile("matchlog.csv", function() {
+    Util.hideModal();
+    getData();
+  });
+});
+// Called when app starts
+function onInit() {
+  getData();
+}
+
+    </script>
+  </body>
+</html>
diff --git a/apps/umpire/metadata.json b/apps/umpire/metadata.json
new file mode 100644
index 0000000000..2cfdb81194
--- /dev/null
+++ b/apps/umpire/metadata.json
@@ -0,0 +1,18 @@
+{ "id": "umpire",
+  "name": "Umpire Ball Counter",
+  "shortName":"Umpire",
+  "icon": "app.png",
+  "version": "0.01",
+  "description": "Cricket umpire ball counter and match event logger",
+  "tags": "outdoors,tool,health,sports,referee,judge,assistant",
+  "readme":"README.md",
+  "interface": "interface.html",
+  "supports": ["BANGLEJS2"],
+  "allow_emulator": true,
+  "storage": [
+    {"name":"umpire.app.js","url":"app.js"},
+    {"name":"umpire.settings.js","url":"settings.js"},
+    {"name":"umpire.img","url":"app-icon.js","evaluate":true}
+  ],
+  "data": [{"name":"umpire.json"}]
+}
diff --git a/apps/umpire/settings.js b/apps/umpire/settings.js
new file mode 100644
index 0000000000..e40e656bce
--- /dev/null
+++ b/apps/umpire/settings.js
@@ -0,0 +1,43 @@
+(function(back) {
+  var FILE = "umpire.json";
+  // Load settings
+  var settings = Object.assign({
+    ballsPerOver: 6,
+    oversPerInnings: 40,
+    heartRateLimit: 130
+  }, require('Storage').readJSON(FILE, true) || {});
+
+  function writeSettings() {
+    require('Storage').writeJSON(FILE, settings);
+  }
+
+  // Show the menu
+  E.showMenu({
+    "" : { "title" : "Umpire Ball Counter" },
+    "< Back" : () => back(),
+    'Balls per over': {
+      value: settings.ballsPerOver,
+      min: 4, max: 10,
+      onchange: v => {
+        settings.ballsPerOver = v;
+        writeSettings();
+      }
+    },
+    'Overs per inn.': {
+      value: settings.oversPerInnings,
+      min: 12, max: 50,
+      onchange: v => {
+        settings.oversPerInnings = v;
+        writeSettings();
+      }
+    },
+    'Auto-log HRM': {
+      value: settings.heartRateLimit,
+      min: 100, max: 200,
+      onchange: v => {
+        settings.heartRateLimit = v;
+        writeSettings();
+      }
+    }
+  });
+})


Reviews:
user: login: bobrippling
body: Looks great, thanks! I've got a few little comments
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-13T21:30:58Z
user: login: thyttan
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-13T22:11:56Z
user: login: thyttan
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-13T22:36:26Z
user: login: bobrippling
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-14T07:20:52Z
user: login: gfwilliams
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-14T07:56:01Z

Review comments:
diff_hunk: @@ -0,0 +1,94 @@
+# Umpire Ball Counter
+*Cricket umpire ball counter and match event logger for Bangle.js 2 hackable smart watch*
+
+![Bangle.js2 smart watch in a collage of screenshots](20250511_210249-COLLAGE.jpg)
+
+## Background
+There are a few ways to keep track of balls when umpiring a cricket match. These vary from the rudimentary - having some stones in your pockets and transferring them from one side to the other - to the "modern" umpire's clicker. The latter allows you to track fairly delivered balls, overs completed and wickets fallen.
+
+In addition, the umpire needs to know the time and to record events like when "Play" was called or when a fielder left the field. These events are written down on paper.
+
+The umpire also needs to assess whether either team is timewasting and whether a new batter has made it out to the crease on time, as required by the Laws.
+
+This app is designed to replace the umpire clicker, and keep track of events for analysis and reporting after each match.
+
+## Objectives ##
+
+The app has three objectives:
+1. Count how many balls and wickets have been logged, show the current over number and the time.
+2. Log these and other events with a timestamp and a duration since the last event.
+3. Allow the data to be viewed on the watch and on a BLE-paired mobile device as structured data.
+
+## Modules ##
+
+The app is split into three screens shown to the umpire:
+1. Main Menu
+2. In-play Screen
+3. Log viewer
+
+There is an additional module to allow settings to be changed via the watch Settings menu; the number of balls in the over and the number of overs in the innings are configurable, so that 15 eight-ball overs, T20, 40-50 overs and The Hundred can all be supported.
+
+A further module of the app is the web page used to view the log file in the app loader. The file can be deleted or a copy downloaded.
+
+## Interaction ##
+
+Bangle.js 2 app interactions are governed by the Bangle and Espruino (E) libraries. This app uses the following patterns:
+
+### Swipe and Button ###
+
+The in-play screen detects swipe and button events using Bangle.setUI. This appears reliable. 
+
+When in-play, use of the button increments the (fairly delivered) ball count. The app does not suppress the screen lock so by default it requires the umpire to press once to unlock the watch and a second press to log the ball. Bangle.buzz is used to give positive feedback when the app takes action so that the screen does not need to be looked at.
+
+After logging the third-to-last ball in the over, the app buzzes twice. This is typically when the umpire needs to check the balls remaining with the other umpire, in case they have not logged every fair delivery.
+
+After the second-to-last ball the app gives one long buzz to remind the umpire that the next ball will close the over (if fairly delivered).
+
+Logging the final ball triggers the change of over. 
+
+When the last over of the innings is complete the screen shows "END" but allows play to continue to be logged until the new innings is triggered.
+
+Whilst the in-play screen is displayed swiping will cause the following actions to occur:
+- **Swipe Up** performs the same action as the Button press, incrementing the ball count.
+- **Swipe Down** decrements the ball count and logs the action as a "Correction".
+- **Swipe Right** shows the log viewer. 
+- **Swipe Left** shows the main menu.
+
+### Confirmation Prompts ###
+
+When choosing "Wicket" or "Recall" from the main menu, E.showPrompt is used to ask for positive confirmation before incrementing or decrementing the wickets, respectively. Those functions also increment or decrement the ball count. E.showPrompt offers relatively small tap targets on the Bangle.js 2 screen but appears stable.
+
+### Scrollers ###
+
+The main menu and log viewer both use E.showScroller to display a scrollable list of tappable items. This is in preference to E.showMenu which, at the time of writing, has bugs affecting where the touch event is detected in the list of menu items (one lower than it should).
path: apps/umpire/README.md
user: login: bobrippling
body: Probably worth opening an issue for this, I remember seeing it when widgets are brought in - @gfwilliams @thyttan have either of you seen this before?
created_at: 2025-05-13T21:23:02Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,435 @@
+// settings and environment 
+// (not loaded again while running)
+var settings = Object.assign({
+  // default values
+  ballsPerOver: 6,
+  oversPerInnings: 40,
+  heartRateLimit: 100
+}, require('Storage').readJSON("umpire.json", true) || {});
+const BALLS_PER_OVER = settings.ballsPerOver;
+const OVERS_PER_INNINGS = settings.oversPerInnings;
+const HEART_RATE_LIMIT = settings.heartRateLimit;
+delete settings;
+const TIMEZONE_OFFSET_HOURS = (new Date()).getTimezoneOffset() / 60;
+const STEP_COUNT_OFFSET = Bangle.getStepCount();
+const BALL_TO_COME_CHAR = '-';
+const BALL_FACED_CHAR = '=';
+
+// globals
+var processing = true; //debounce to inhibit twist events
+var wickets = 0;
+var counter = 0;
+var over = 0;
+var ballTimes = [];
+var overTimes = [];
+var timeTimes = [];
+var log = [];
+var timeCalled = false;
+var batteryPercents = [];
+var battery = 0;
+var heartRate = '';
+var heartRateEventSeconds = 0;
+var HRM = false;
+var lastSteps = STEP_COUNT_OFFSET;
+
+function toggleHRM() {
+  if(HRM) {
+    Bangle.setHRMPower(0);
+    HRM = false;
+    heartRateEventSeconds = 0;
+    heartRate = '';
+  } else {
+    Bangle.setHRMPower(1);
+    HRM = true;
+  }
+}
+
+function getBattery() {
+  // calculate last 10 moving average %
+  batteryPercents.push(E.getBattery());
+  if(batteryPercents.length > 10) batteryPercents.shift();
+  return Math.round(batteryPercents.reduce((avg,e,i,arr)=>avg+e/arr.length,0));
+}
+
+// process heart rate monitor event 
+// each second (approx.)
+function updateHeartRate(h) {
+  heartRate = h.bpm || 0;
+  if(heartRate >= HEART_RATE_LIMIT) {
+    heartRateEventSeconds++;
+    if(heartRateEventSeconds==10) 
+      addLog((new Date()), over, counter, 
+        "Heart Rate", ">" + HEART_RATE_LIMIT);
+  }
+  if(heartRateEventSeconds > 10 
+     && heartRate < HEART_RATE_LIMIT) 
+      heartRateEventSeconds = -10;
+}
+
+// write events to storage (csv, persistent) 
+// and memory (can be truncated while running)
+function addLog(timeSig, over, ball, matchEvent, metaData) {
+  var steps = Bangle.getStepCount() - STEP_COUNT_OFFSET;
+  // write to storage
+  var csv = [
+    formatTimeOfDay(timeSig),
+    over-1, ball, 
+    matchEvent, metaData, 
+    steps, battery, heartRate
+  ];
+  file.write(csv.join(",")+"\n");
+  // write to memory
+  log.unshift({ // in rev. chrono. order
+    time: formatTimeOfDay(timeSig),
+    over: over-1,
+    ball: ball,
+    matchEvent: matchEvent,
+    metaData: metaData,
+    steps: steps,
+    battery: battery,
+    heartRate: heartRate
+  });
+}
+
+// display log from memory (not csv)
+function showLog() {
+  processing = true;
+  Bangle.setUI();
+  return E.showScroller({
+    h: 50, c: log.length,
+    draw: (idx, r) => {
+      g.setBgColor((idx&1)?"#000":"#112").clearRect(r.x,r.y,r.x+r.w-1,r.y+r.h-1);
+      if(log[idx].matchEvent==/*LANG*/"Over Duration"
+        || log[idx].matchEvent==/*LANG*/"Innings Duration") {
+        g.setFont("Vector", 22).drawString(
+          log[idx].matchEvent,r.x+6,r.y+2);
+      } else {
+        g.setFont("Vector", 22).drawString(
+          log[idx].over + "." +
+          log[idx].ball + " " +
+          log[idx].matchEvent,r.x+6,r.y+2);
+      }
+      g.setFont("Vector", 18).drawString(
+        log[idx].time + " " +
+        log[idx].metaData + " " +
+        log[idx].heartRate,r.x+6,r.y+27);
+    },
+    select: (idx) => {
+      resumeGame();
+    }
+  });
+}
+
+// format date (diff) as duration
+function formatDuration(timeDate) { 
+  return (timeDate.getHours() + TIMEZONE_OFFSET_HOURS) + ":" 
+    + timeDate.getMinutes().toString().padStart(2, "0") + ":" 
+    + timeDate.getSeconds().toString().padStart(2, "0") + "";
+}
+
+// format date as clock
+function formatTimeOfDay(timeSig) { 
+  return timeSig.getHours() + ":" 
+    + timeSig.getMinutes().toString().padStart(2, "0");
+}
+
+// main ball counter logic
+// and in-play screen
+function countDown(dir) {
+  processing = true;
+  battery = getBattery(); // refresh battery
+  counter += dir;
+  // suppress correction on first ball of innings
+  if(over==1 && counter<0) {
+    counter=0;
+    processing = false;
+    return;
+  }
+  // Suppress dir when play after time
+  if(timeCalled)
+    counter -= dir;
+  // Correction to last ball of over
+  if(counter<0) {
+    counter = BALLS_PER_OVER -1;
+    over -= 1;
+    // use end of over time as last ball time
+    ballTimes.push(overTimes.pop());
+  }
+  // create timestamp for log
+  var timeSig = new Date();
+  // calculate elapsed since last ball
+  var lastBallTime = timeSig.getTime();
+  if(ballTimes.length>0) {
+    lastBallTime = ballTimes[ballTimes.length - 1];
+  } else if(overTimes.length>0) {
+    lastBallTime = overTimes[overTimes.length - 1];
+  }
+  var deadDuration = new Date(
+    timeSig.getTime() - lastBallTime);
+  // process new (dead) ball
+  if(dir!=0) {
+    // call play after time?
+    if(timeCalled) {
+      timeCalled = false;
+      // resume heart rate monitoring
+      if(HRM) Bangle.setHRMPower(1);
+      // calculate time lost and log it
+      var lastTimeTime = timeTimes[timeTimes.length - 1];
+      var timeDuration = new Date(
+        timeSig.getTime() - lastTimeTime);
+      addLog(timeSig, over, counter, 
+        "Play", /*LANG*/"Lost:" + formatDuration(timeDuration));    
+    } else {
+      if(counter>0) // reset elapsed time
+        ballTimes.push(timeSig.getTime());
+      Bangle.setLCDPower(1); //TODO need any more?
+    
+      if(dir>0) { // fairly delivered ball
+        addLog(timeSig, over, counter, 
+          "Ball", formatDuration(deadDuration));
+      } else { // +1 ball still to come
+        addLog(timeSig, over, counter, 
+          /*LANG*/"Correction", formatDuration(deadDuration));
+      }
+    }
+    // give haptic feedback
+    if(counter == BALLS_PER_OVER - 2) {
+      // buzz twice "2 to come"
+      Bangle.buzz(400).then(()=>{
+        return new Promise(
+          resolve=>setTimeout(resolve,500));
+      }).then(()=>{
+        return Bangle.buzz(500);
+      })
+    } else if(counter == BALLS_PER_OVER - 1) {
+      // long buzz "1 to come"
+      Bangle.buzz(800);
+    } else {
+      // otherwise short buzz
+      Bangle.buzz()
+    }
+    // Process end of over
+    if (counter == BALLS_PER_OVER) {
+      // calculate match time
+      var matchDuration = new Date(
+        timeSig.getTime() - overTimes[0]);
+      var matchMinutesString = formatDuration(matchDuration);
+      // calculate over time
+      var overDuration = new Date(
+        timeSig.getTime() - overTimes[overTimes.length - 1]);
+      var overMinutesString = formatDuration(overDuration);  
+      // log end of over
+      addLog(timeSig, over + 1, 0, 
+        /*LANG*/"Over Duration", overMinutesString);
+      addLog(timeSig, over + 1, 0, 
+        /*LANG*/"Innings Duration", matchMinutesString);
+      overTimes.push(timeSig.getTime());
+      // start new over
+      over += 1;
+      counter = 0; 
+      ballTimes = [];
+    }
+  }
+  // refresh in-play screen
+  g.clear(1);
+  // draw wickets fallen (top-right)
+  g.setFontAlign(1,0);
+  g.setFont("Vector",26).
+   drawString(wickets, 162, 14, true);
+  g.setFont("Vector",12).
+   drawString('\¦\¦\¦', 173, 15, true);
+  // draw battery and heart rate (top-left)
+  g.setFontAlign(-1,0);
+  var heartRateString = 'HR:' + heartRate;
+  if(heartRateEventSeconds <= 0) heartRateString = '';
+  g.setFont("Vector",16).
+    drawString(battery + '% ' + heartRateString, 5, 11, true);
+  // draw clock (upper-centre)
+  g.setFontAlign(0,0);
+  g.setFont("Vector",48).
+    drawString(formatTimeOfDay(timeSig), 93, 55, true);
+  // draw over.ball (centre)
+  var ballString = (over-1) + "." + counter;
+  if(over > OVERS_PER_INNINGS) 
+    ballString = 'END';
+  g.setFont("Vector",80).
+    drawString(ballString, 93, 120, true);
+  // draw ball graph and elapsed time
+  var ballGraph = 
+    BALL_FACED_CHAR.repeat(counter) 
+    + BALL_TO_COME_CHAR.repeat(BALLS_PER_OVER - counter);
+  if(timeCalled) ballGraph = '-TIME-';
+  g.setFont("Vector",18).drawString(
+    ballGraph + ' ' + formatDuration(deadDuration), 93, 166, true);
+  // return to wait for next input
+  processing = false;
+}
+
+function resumeGame(play) {
+  processing = true;
+  Bangle.buzz();
+  Bangle.setUI({
+      mode: "custom",
+      swipe: (directionLR, directionUD)=>{
+        if (directionLR==-1) { 
+          processing = true;
+          menu = showMainMenu();
+        } else if (directionLR==1) { 
+          processing = true;
+          menu = showLog();
+        } else if (directionUD==-1) { 
+          processing = true;
+          countDown(1);
+        } else {
+          processing = true;
+          countDown(-1);
+        }
+      },
+      btn: ()=>{
+        processing = true;
+        countDown(1);
+      }
+    });
+  if(over==0) { // at start of innings
+    over += 1; // N.B. 1-based overs in code
+    counter = 0; // balls
+    ballTimes = [];
+    // set an inital time for comparison  
+    var timeSig = new Date();
+    overTimes.push(timeSig.getTime());
+    addLog(timeSig, over, counter, 
+      "Play", "");        
+    // set up twist refresh once only
+    Bangle.on('twist', function() { 
+      if(!processing) {
+        processing = true; // debounce
+        countDown(0);
+      }
+    });
+    // set up HRM listener once only
+    Bangle.on('HRM', function(h) {
path: apps/umpire/app.js
user: login: bobrippling
body: If this is called multiple times it'll add more functions to the hrm event, perhaps you'll want to remove the previous listener first
created_at: 2025-05-13T21:28:50Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,3 @@
+1.00: Beta version
+1.10: Added Heart Rate Monitoring
+1.20: General release
path: apps/umpire/ChangeLog
user: login: thyttan
body: We usually keep to `0.xx` for version numbers and I'd prefer that here as well.

If no one else has started using the app yet I suggest you restart at `0.01` and increment from there next time you update the app.
created_at: 2025-05-13T22:11:15Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,94 @@
+# Umpire Ball Counter
+*Cricket umpire ball counter and match event logger for Bangle.js 2 hackable smart watch*
+
+![Bangle.js2 smart watch in a collage of screenshots](20250511_210249-COLLAGE.jpg)
+
+## Background
+There are a few ways to keep track of balls when umpiring a cricket match. These vary from the rudimentary - having some stones in your pockets and transferring them from one side to the other - to the "modern" umpire's clicker. The latter allows you to track fairly delivered balls, overs completed and wickets fallen.
+
+In addition, the umpire needs to know the time and to record events like when "Play" was called or when a fielder left the field. These events are written down on paper.
+
+The umpire also needs to assess whether either team is timewasting and whether a new batter has made it out to the crease on time, as required by the Laws.
+
+This app is designed to replace the umpire clicker, and keep track of events for analysis and reporting after each match.
+
+## Objectives ##
+
+The app has three objectives:
+1. Count how many balls and wickets have been logged, show the current over number and the time.
+2. Log these and other events with a timestamp and a duration since the last event.
+3. Allow the data to be viewed on the watch and on a BLE-paired mobile device as structured data.
+
+## Modules ##
+
+The app is split into three screens shown to the umpire:
+1. Main Menu
+2. In-play Screen
+3. Log viewer
+
+There is an additional module to allow settings to be changed via the watch Settings menu; the number of balls in the over and the number of overs in the innings are configurable, so that 15 eight-ball overs, T20, 40-50 overs and The Hundred can all be supported.
+
+A further module of the app is the web page used to view the log file in the app loader. The file can be deleted or a copy downloaded.
+
+## Interaction ##
+
+Bangle.js 2 app interactions are governed by the Bangle and Espruino (E) libraries. This app uses the following patterns:
+
+### Swipe and Button ###
+
+The in-play screen detects swipe and button events using Bangle.setUI. This appears reliable. 
+
+When in-play, use of the button increments the (fairly delivered) ball count. The app does not suppress the screen lock so by default it requires the umpire to press once to unlock the watch and a second press to log the ball. Bangle.buzz is used to give positive feedback when the app takes action so that the screen does not need to be looked at.
+
+After logging the third-to-last ball in the over, the app buzzes twice. This is typically when the umpire needs to check the balls remaining with the other umpire, in case they have not logged every fair delivery.
+
+After the second-to-last ball the app gives one long buzz to remind the umpire that the next ball will close the over (if fairly delivered).
+
+Logging the final ball triggers the change of over. 
+
+When the last over of the innings is complete the screen shows "END" but allows play to continue to be logged until the new innings is triggered.
+
+Whilst the in-play screen is displayed swiping will cause the following actions to occur:
+- **Swipe Up** performs the same action as the Button press, incrementing the ball count.
+- **Swipe Down** decrements the ball count and logs the action as a "Correction".
+- **Swipe Right** shows the log viewer. 
+- **Swipe Left** shows the main menu.
+
+### Confirmation Prompts ###
+
+When choosing "Wicket" or "Recall" from the main menu, E.showPrompt is used to ask for positive confirmation before incrementing or decrementing the wickets, respectively. Those functions also increment or decrement the ball count. E.showPrompt offers relatively small tap targets on the Bangle.js 2 screen but appears stable.
+
+### Scrollers ###
+
+The main menu and log viewer both use E.showScroller to display a scrollable list of tappable items. This is in preference to E.showMenu which, at the time of writing, has bugs affecting where the touch event is detected in the list of menu items (one lower than it should).
path: apps/umpire/README.md
user: login: thyttan
body: I'm not sure. A week or two ago I maybe noticed something similar with showScroller. I had initiated it with widgets showing but immediately after hid them, and I think then drawing was a bit wonky and maybe input as well. Not sure.
created_at: 2025-05-13T22:36:26Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,94 @@
+# Umpire Ball Counter
+*Cricket umpire ball counter and match event logger for Bangle.js 2 hackable smart watch*
+
+![Bangle.js2 smart watch in a collage of screenshots](20250511_210249-COLLAGE.jpg)
+
+## Background
+There are a few ways to keep track of balls when umpiring a cricket match. These vary from the rudimentary - having some stones in your pockets and transferring them from one side to the other - to the "modern" umpire's clicker. The latter allows you to track fairly delivered balls, overs completed and wickets fallen.
+
+In addition, the umpire needs to know the time and to record events like when "Play" was called or when a fielder left the field. These events are written down on paper.
+
+The umpire also needs to assess whether either team is timewasting and whether a new batter has made it out to the crease on time, as required by the Laws.
+
+This app is designed to replace the umpire clicker, and keep track of events for analysis and reporting after each match.
+
+## Objectives ##
+
+The app has three objectives:
+1. Count how many balls and wickets have been logged, show the current over number and the time.
+2. Log these and other events with a timestamp and a duration since the last event.
+3. Allow the data to be viewed on the watch and on a BLE-paired mobile device as structured data.
+
+## Modules ##
+
+The app is split into three screens shown to the umpire:
+1. Main Menu
+2. In-play Screen
+3. Log viewer
+
+There is an additional module to allow settings to be changed via the watch Settings menu; the number of balls in the over and the number of overs in the innings are configurable, so that 15 eight-ball overs, T20, 40-50 overs and The Hundred can all be supported.
+
+A further module of the app is the web page used to view the log file in the app loader. The file can be deleted or a copy downloaded.
+
+## Interaction ##
+
+Bangle.js 2 app interactions are governed by the Bangle and Espruino (E) libraries. This app uses the following patterns:
+
+### Swipe and Button ###
+
+The in-play screen detects swipe and button events using Bangle.setUI. This appears reliable. 
+
+When in-play, use of the button increments the (fairly delivered) ball count. The app does not suppress the screen lock so by default it requires the umpire to press once to unlock the watch and a second press to log the ball. Bangle.buzz is used to give positive feedback when the app takes action so that the screen does not need to be looked at.
+
+After logging the third-to-last ball in the over, the app buzzes twice. This is typically when the umpire needs to check the balls remaining with the other umpire, in case they have not logged every fair delivery.
+
+After the second-to-last ball the app gives one long buzz to remind the umpire that the next ball will close the over (if fairly delivered).
+
+Logging the final ball triggers the change of over. 
+
+When the last over of the innings is complete the screen shows "END" but allows play to continue to be logged until the new innings is triggered.
+
+Whilst the in-play screen is displayed swiping will cause the following actions to occur:
+- **Swipe Up** performs the same action as the Button press, incrementing the ball count.
+- **Swipe Down** decrements the ball count and logs the action as a "Correction".
+- **Swipe Right** shows the log viewer. 
+- **Swipe Left** shows the main menu.
+
+### Confirmation Prompts ###
+
+When choosing "Wicket" or "Recall" from the main menu, E.showPrompt is used to ask for positive confirmation before incrementing or decrementing the wickets, respectively. Those functions also increment or decrement the ball count. E.showPrompt offers relatively small tap targets on the Bangle.js 2 screen but appears stable.
+
+### Scrollers ###
+
+The main menu and log viewer both use E.showScroller to display a scrollable list of tappable items. This is in preference to E.showMenu which, at the time of writing, has bugs affecting where the touch event is detected in the list of menu items (one lower than it should).
path: apps/umpire/README.md
user: login: bobrippling
body: Interesting - well I'll not block this PR because of it but will keep my eye out for it happening
created_at: 2025-05-14T07:20:52Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,94 @@
+# Umpire Ball Counter
+*Cricket umpire ball counter and match event logger for Bangle.js 2 hackable smart watch*
+
+![Bangle.js2 smart watch in a collage of screenshots](20250511_210249-COLLAGE.jpg)
+
+## Background
+There are a few ways to keep track of balls when umpiring a cricket match. These vary from the rudimentary - having some stones in your pockets and transferring them from one side to the other - to the "modern" umpire's clicker. The latter allows you to track fairly delivered balls, overs completed and wickets fallen.
+
+In addition, the umpire needs to know the time and to record events like when "Play" was called or when a fielder left the field. These events are written down on paper.
+
+The umpire also needs to assess whether either team is timewasting and whether a new batter has made it out to the crease on time, as required by the Laws.
+
+This app is designed to replace the umpire clicker, and keep track of events for analysis and reporting after each match.
+
+## Objectives ##
+
+The app has three objectives:
+1. Count how many balls and wickets have been logged, show the current over number and the time.
+2. Log these and other events with a timestamp and a duration since the last event.
+3. Allow the data to be viewed on the watch and on a BLE-paired mobile device as structured data.
+
+## Modules ##
+
+The app is split into three screens shown to the umpire:
+1. Main Menu
+2. In-play Screen
+3. Log viewer
+
+There is an additional module to allow settings to be changed via the watch Settings menu; the number of balls in the over and the number of overs in the innings are configurable, so that 15 eight-ball overs, T20, 40-50 overs and The Hundred can all be supported.
+
+A further module of the app is the web page used to view the log file in the app loader. The file can be deleted or a copy downloaded.
+
+## Interaction ##
+
+Bangle.js 2 app interactions are governed by the Bangle and Espruino (E) libraries. This app uses the following patterns:
+
+### Swipe and Button ###
+
+The in-play screen detects swipe and button events using Bangle.setUI. This appears reliable. 
+
+When in-play, use of the button increments the (fairly delivered) ball count. The app does not suppress the screen lock so by default it requires the umpire to press once to unlock the watch and a second press to log the ball. Bangle.buzz is used to give positive feedback when the app takes action so that the screen does not need to be looked at.
+
+After logging the third-to-last ball in the over, the app buzzes twice. This is typically when the umpire needs to check the balls remaining with the other umpire, in case they have not logged every fair delivery.
+
+After the second-to-last ball the app gives one long buzz to remind the umpire that the next ball will close the over (if fairly delivered).
+
+Logging the final ball triggers the change of over. 
+
+When the last over of the innings is complete the screen shows "END" but allows play to continue to be logged until the new innings is triggered.
+
+Whilst the in-play screen is displayed swiping will cause the following actions to occur:
+- **Swipe Up** performs the same action as the Button press, incrementing the ball count.
+- **Swipe Down** decrements the ball count and logs the action as a "Correction".
+- **Swipe Right** shows the log viewer. 
+- **Swipe Left** shows the main menu.
+
+### Confirmation Prompts ###
+
+When choosing "Wicket" or "Recall" from the main menu, E.showPrompt is used to ask for positive confirmation before incrementing or decrementing the wickets, respectively. Those functions also increment or decrement the ball count. E.showPrompt offers relatively small tap targets on the Bangle.js 2 screen but appears stable.
+
+### Scrollers ###
+
+The main menu and log viewer both use E.showScroller to display a scrollable list of tappable items. This is in preference to E.showMenu which, at the time of writing, has bugs affecting where the touch event is detected in the list of menu items (one lower than it should).
path: apps/umpire/README.md
user: login: gfwilliams
body: @AndySmee if you could provide more info on this in an issue (when using 2v26 firmware, and making sure your touchscreen is calibrated) that'd be hugely helpful. E.showMenu uses E.showScroller under the hood anyway, so it's kind of surprising there's an issue.

But we use E.showMenu for so much on Bangle.js (Settings/etc) that if there's an issue I'd really like to get it sorted rather than having app developers trying not to use it!
created_at: 2025-05-14T07:56:01Z
author_association: MEMBER

PR comments:
user: login: AndySmee
created_at: 2025-05-13T21:44:36Z
body: On the first issue, I have tried calibrating the touch screen and it may be better, it's probably not an issue so much as a limitation of the screen space with fat fingers. I'll remove the comment from the readme as I don't need the justification there.I was concerned about that repeat calls to listen .on HRM, so I'll do as you suggested.AndySent from Outlook for AndroidFrom: Rob Pilling ***@***.***>Sent: Tuesday, May 13, 2025 10:32:04 pmTo: espruino/BangleApps ***@***.***>Cc: Andy Smy ***@***.***>; Author ***@***.***>Subject: Re: [espruino/BangleApps] Umpire Ball Counter app (PR ***@***.*** commented on this pull request.Looks great, thanks! I've got a few little commentsIn apps/umpire/README.md:> +
+When the last over of the innings is complete the screen shows "END" but allows play to continue to be logged until the new innings is triggered.
+
+Whilst the in-play screen is displayed swiping will cause the following actions to occur:
+- **Swipe Up** performs the same action as the Button press, incrementing the ball count.
+- **Swipe Down** decrements the ball count and logs the action as a "Correction".
+- **Swipe Right** shows the log viewer. 
+- **Swipe Left** shows the main menu.
+
+### Confirmation Prompts ###
+
+When choosing "Wicket" or "Recall" from the main menu, E.showPrompt is used to ask for positive confirmation before incrementing or decrementing the wickets, respectively. Those functions also increment or decrement the ball count. E.showPrompt offers relatively small tap targets on the Bangle.js 2 screen but appears stable.
+
+### Scrollers ###
+
+The main menu and log viewer both use E.showScroller to display a scrollable list of tappable items. This is in preference to E.showMenu which, at the time of writing, has bugs affecting where the touch event is detected in the list of menu items (one lower than it should).
Probably worth opening an issue for this, I remember seeing it when widgets are brought in - @gfwilliams @thyttan have either of you seen this before?In apps/umpire/app.js:> +    counter = 0; // balls
+    ballTimes = [];
+    // set an inital time for comparison  
+    var timeSig = new Date();
+    overTimes.push(timeSig.getTime());
+    addLog(timeSig, over, counter, 
+      "Play", "");        
+    // set up twist refresh once only
+    Bangle.on('twist', function() { 
+      if(!processing) {
+        processing = true; // debounce
+        countDown(0);
+      }
+    });
+    // set up HRM listener once only
+    Bangle.on('HRM', function(h) {
If this is called multiple times it'll add more functions to the hrm event, perhaps you'll want to remove the previous listener first—Reply to this email directly, view it on GitHub, or unsubscribe.You are receiving this because you authored the thread.
author_association: CONTRIBUTOR
user: login: AndySmee
created_at: 2025-05-13T22:29:26Z
body: Done, along with other issues raised. ThanksSent from Outlook for AndroidFrom: thyttan ***@***.***>Sent: Tuesday, May 13, 2025 11:13:03 pmTo: espruino/BangleApps ***@***.***>Cc: Andy Smy ***@***.***>; Author ***@***.***>Subject: Re: [espruino/BangleApps] Umpire Ball Counter app (PR ***@***.*** commented on this pull request.In apps/umpire/ChangeLog:> @@ -0,0 +1,3 @@
+1.00: Beta version
+1.10: Added Heart Rate Monitoring
+1.20: General release
We usually keep to 0.xx for version numbers and I'd prefer that here as well.If no one else has started using the app yet I suggest you restart at 0.01 and increment from there next time you update the app.—Reply to this email directly, view it on GitHub, or unsubscribe.You are receiving this because you authored the thread.
author_association: CONTRIBUTOR
user: login: bobrippling
created_at: 2025-05-14T07:21:05Z
body: Thanks, LGTM - @thyttan happy to merge?
author_association: COLLABORATOR
user: login: AndySmee
created_at: 2025-05-14T07:45:46Z
body: Forgive my ignorance, do I need to start a new branch to continue development while this PR is at this stage?
author_association: CONTRIBUTOR
user: login: gfwilliams
created_at: 2025-05-14T07:53:13Z
body: > do I need to start a new branch to continue development while this PR is at this stage?

Yes, until it's merged - otherwise any changes you make to your online github repository will go into this PR.

If you're developing locally on your PC with a Git repo there (and not pushing your changes online) then it's fine though
author_association: MEMBER
user: login: thyttan
created_at: 2025-05-14T08:07:44Z
body: I'll merge now though!
author_association: COLLABORATOR
user: login: thyttan
created_at: 2025-05-14T08:10:00Z
body: Thanks!
author_association: COLLABORATOR
user: login: AndySmee
created_at: 2025-05-14T08:10:55Z
body: > > do I need to start a new branch to continue development while this PR is at this stage?
> 
> Yes, until it's merged - otherwise any changes you make to your online github repository will go into this PR.
> 
> If you're developing locally on your PC with a Git repo there (and not pushing your changes online) then it's fine though

Thanks. I developed the entire thing in github editor on my phone, which is the great thing about Bangle dev 😊 so all online at the moment. Thanks for reviewing PRs and keeping Bangle going! 🤩
author_association: CONTRIBUTOR