• v1.0.0-Beta.06 8b37ecec66

    v1.0.0-Beta.06
    All checks were successful
    CI / check (push) Successful in 1m26s
    Release / release (push) Successful in 2m11s
    Docker Release / build-and-push (push) Successful in 4m14s
    CI / tests (push) Successful in 5m10s
    Pre-release

    k4wit0 released this 2026-09-01 18:56:33 +02:00 | 56 commits to main since this release

    The status bar on iOS, and an installer that reads like the others

    Fixed

    • On an iPhone the application no longer draws under the status bar. With
      viewport-fit=cover and a translucent status bar, iOS hands the whole
      display to the page — and the top of it was never given back, so the greeting
      and the connection dot sat behind the clock and the battery. The bottom inset
      had been handled in four places from the start; the top in none, and there is
      nothing to notice on a device without a notch, which is why the same build
      looked right on Android.

      The strip is reserved on the shell rather than on the scroll container, and
      that distinction is the fix: a scroller's own padding scrolls with what is
      inside it, so content would have slid up through the strip and passed behind
      the clock in full view — the same trap that once put a band above every
      sticky header.

    Changed

    • The installer reads like Lastboard's, because it is the same installer.
      Same greeting, same steps in the same order and the same words, the same
      "Installation complete!" and the same closing line with the address. What was
      there before was a different script doing the same job in a different voice —
      lowercase steps with no ellipsis, a block of five facts nobody asked for, and
      an ending that named a commit hash.

      Three things stay different, and each because the products differ. A port in
      use is reported rather than freed: Lastboard kills whatever holds it, and on a
      machine where that is somebody's other service it is an outage caused by an
      installer. The signature is checked, and says so, because these releases are
      signed and Lastboard's are not. And an upgrade keeps the configuration file
      untouched and says so, rather than rewriting it.

    Downloads
  • v1.0.0-Beta.05 14e2a7120b

    v1.0.0-Beta.05
    All checks were successful
    CI / check (push) Successful in 1m26s
    Release / release (push) Successful in 2m2s
    Docker Release / build-and-push (push) Successful in 4m29s
    CI / tests (push) Successful in 5m8s
    Pre-release

    k4wit0 released this 2026-09-01 18:07:30 +02:00 | 62 commits to main since this release

    The phone: icon, zones, sessions that end and imports that speak

    Fixed

    • The installed application wears the mascot on a phone. The manifest declared
      one icon and it was an SVG; Chrome on Android will not take a vector for a
      home-screen icon, and with nothing it could use it kept whatever it already
      had — the dumbbell that came before the mascot. The same drawing now ships as
      PNG at 192 and 512, with a square-edged 512 for maskable so the platform's
      own mask has a full plate to cut from, and an apple-touch-icon because iOS
      never reads the manifest for that one.
    • The icon is one named face rather than whichever one was on screen.
      scripts/icon.ts took the first svg.mascot it found, and which of the ten
      moods that is depends on the account that happened to be open — so the
      identity of the application was decided by somebody's training week, and a
      rebuild could quietly change the face on every home screen. It signs out
      first, asks for meeting by name, and refuses to write anything if that face
      is not there.
    • The icon's plate is the canvas colour. It was left on #121212 when the
      canvas moved, so it sat a shade lighter than the screen it opens.
    • Finishing a session ends it. finish() recorded the workout and then left it
      in the signal, and the Train tab asks that signal before it asks the device —
      so touching Train after finishing walked straight back into the workout that
      had just ended, every exercise still there, and the only way out was the menu
      again. clear() existed for exactly this and nothing had ever called it.
    • A session says when it is over. Every exercise asks "done, or one more?" when
      its sets are in; the last one was answered and the screen then sat there with
      nothing owing, nothing to do and nothing saying so. The way out was behind
      the three dots, which is where somebody goes to abandon a session rather than
      to complete one.
    • Importing an activity says it worked. The message was written into the notice
      and then onChanged redrew the screen, throwing away the node it had just
      been written to — so a successful import said nothing at all, and only the
      new session appearing behind the sheet suggested anything had happened. It is
      a toast now, which lives outside what is being redrawn. Errors stay in place:
      a failure is to read and act on, not to glance at before it slides away.
    • The upload sheet closes when the file is in, and its file control is a button
      of ours rather than the browser's grey "Choose file / No file chosen" under a
      label repeating the title of the sheet it was in.
    • Time in zones is laid out. .zone-bars was a class with no rule anywhere in
      the stylesheet, so it rendered as the browser's own list — discs down the
      margin, and three inline spans with nothing between them, which ran the
      zone's range and the time spent in it together as one number: "Zona 1
      114–1265:55". Nothing looks wrong reading the stylesheet, because the rule
      that would be wrong is not there. Each zone is now its name, its range and
      its time on one line with the bar underneath at full width, and a zone with
      no time in it draws an empty track rather than a stub that says "a little".
    • A watch file can be chosen on a phone. Chrome on Android turns the file
      input's accept list into an intent filter, and it can only use the entries
      it can map to a MIME type — .fit and .tcx are not in Android's table, so
      what reached the picker was "XML" and a watch file was not something that
      could be picked. The same screen worked from a computer, which is how a
      filter that exists on one platform only manages to hide.
    Downloads
  • v1.0.0-Beta.04 6a39713981

    v1.0.0-Beta.04
    All checks were successful
    CI / check (push) Successful in 1m30s
    Release / release (push) Successful in 2m11s
    Docker Release / build-and-push (push) Successful in 4m19s
    CI / tests (push) Successful in 5m7s
    Pre-release

    k4wit0 released this 2026-09-01 15:36:11 +02:00 | 68 commits to main since this release

    Photographs, real watch files, and updating with people training

    Added

    • Photographs have a screen of their own at /body/photos: a grid of
      thumbnails grouped by the month they were taken, newest first, one tap to see
      one large, and swiping across it to move through them. Adding is two buttons
      of ours — take one, or choose one — rather than the browser's grey "Choose
      file / No file chosen", which is a web page announcing itself on a phone.
      Comparing is a mode entered from the header, and it appears only when there
      are two photographs to compare.

    • A third answer to a synchronisation batch: deferred. It means this server
      cannot take the change yet, and a later one may.

      There were two, and they were answers to different questions. "This identifier
      belongs to another account" is never going to become true; "this server has
      never heard of that exercise" is a statement about a release, and the sentence
      after it is usually the administrator updating the server. Both were answered
      with "refused, and will never be accepted", and the client's correct response
      to that is to throw the row away. Measured: four recorded sets refused by the
      server and discarded by the phone in the same second, existing nowhere
      afterwards.

      Deferred work is held on the device, out of the send queue so it is not
      offered every minute, and offered again the first time the phone meets a
      server running a different build. The server now tells clients what build it
      is, for exactly that purpose. Nobody has to be told their session was waiting.

    • The connection detail says how much is held and what it is waiting for, and
      signing out counts it. Held training is unsent training, and that button is
      the last thing between it and a browser somebody else uses.

    Changed

    • The body screen ends in a row that says how many photographs there are and
      opens them, instead of carrying the whole thing. It was 865 pixels of form on
      a screen somebody opens to read a number.

    • An update no longer refuses to run while somebody has a session open. It
      reports who is training and carries on.

      The refusal read well and was backwards. Restarting the server mid-set is not
      the failure the rest of this application was designed against — it is the case
      the rest of it was designed for: a set is written to the phone's own store
      before it appears on screen, a failed request leaves the queue exactly as it
      was, and the phone offers it again by itself. From the gym floor a server
      being updated and a corridor with no signal are the same event. What the
      refusal achieved was letting one person who forgot to close a session block
      every update on the server, and it did not even cover the case that matters —
      somebody who finished half an hour ago with their phone in a locker and
      nothing sent yet. lastgym update --when-idle keeps the old behaviour for an
      administrator who would rather wait, or for a nightly cron.

    Fixed

    • A .fit file from a real watch imports instead of answering 500.

      Two things, and neither showed against the files these tests write for
      themselves. The reader took total_elapsed_time for hundredths of a second
      where the profile scales it by 1000, so every activity came out ten times as
      long as it was — a three-and-a-half hour walk filed as thirty-four hours,
      next to its own twelve thousand samples whose last offset said three and a
      half. And the watch reports moving time 170 milliseconds longer than elapsed
      time, out of those three and a half hours, which the table forbids and SQLite
      refused; from outside it was "an error, and nothing happens".

      A session is now brought inside what the table allows before it is written:
      a number out of range by rounding is clamped, and one that cannot mean
      anything is dropped. internal/core/activity/testdata/walk.fit is a real
      Garmin file, cut down — every definition message as the watch wrote it,
      including the six times it redefines a record as sensors come online — with
      every position zeroed and every timestamp moved off the day it happened.

    • One photograph is shown once. The old screen was a permanent
      before-and-after, so with a single photograph both sides chose the same one
      and it appeared twice, next to itself — which reads as the upload having gone
      wrong. It had not: the file was fine, and its orientation was fine too.

    • The sheet closes when a photograph is added, and the screen behind it
      redraws. It used to empty the file field and stay open, which reads as
      nothing having happened.

    • The local database is opened without naming a version, so an older bundle can
      no longer refuse to start against a newer database.

      A progressive web application serves whatever its service worker has, which is
      not always the newest build. Opening at a fixed version meant that after a
      release which raised it, a tab holding the previous bundle asked for version 1
      against a version 2 database — and IndexedDB refuses to go backwards. The
      start-up screen showed "LastGYM could not start" over the browser's own
      sentence, in English, and the obvious remedy — clearing site data — is the one
      action that would have destroyed the queue. Nothing was ever lost, but the
      application would not open.

      There is no version constant any more. What decides an upgrade is whether the
      stores this build needs are there, and the only version ever requested is one
      higher than what is on disk. An older build meeting a newer database now
      simply works: upgrades only ever add stores, so the extra ones sit unused.

    • Signing in now does everything booting into a session does. It did not, and
      the consequences were only visible in a browser.

      Boot claimed the local store for the account and started synchronising, but
      only when somebody was already signed in — and the person signing in had, by
      definition, not been. So for the whole of the session they had just started:
      no synchronisation, no online listener, no heartbeat, and no flush when the
      application closed, which the code that registers it calls "the last chance".
      Nothing was lost, because none of it is discarded, but nothing reached the
      server until the application was reopened.

      Worse, the store was never claimed for the account that had just arrived.
      Measured in a browser: signed in as a second person on the same device, the
      local database still belonged to the first, with their rows and their sequence
      number in it. Nothing of theirs was on screen — the screens that could show it
      read from the server — but their queue was there, and a queue is sent under
      whoever is signed in now.

    • A parent that has not arrived and a parent that belongs to somebody else were
      the same refusal. Both answered "missing parent", which was invisible while
      every refusal was permanent and stopped being invisible the moment a missing
      parent became something worth waiting for: a set aimed at a stranger's session
      would have been offered again after every update, for ever.

    • The installer no longer prints the updater's output on top of its own. An
      upgrade hands the release to lastgym update, which is where the five
      refusals live, and that command talks to whoever ran it — announcing its own
      fetch, its own signature check and a column of steps, then writing the reason
      it stopped twice. All of it landed in the middle of the installer's lines. It
      is captured now and reported in the installer's own voice: one line for the
      signature, one for the backup when one was really taken, and one for the
      reason when it refuses.

    • An upgrade downloads the binary once instead of twice. The installer fetched
      it and then the updater fetched it again behind its own signature check —
      fifteen megabytes, and the word "fetching" printed twice, which reads like
      the script lost its place.

    • The summary at the top of an install shows the address somebody can open
      rather than the one the process binds to. It binds to every interface on
      purpose, but 0.0.0.0:8080 is an implementation detail printed to somebody
      who wants a link.

    Downloads
  • v1.0.0-Beta.03 fdc0027fff

    v1.0.0-Beta.03
    All checks were successful
    CI / check (push) Successful in 3m18s
    Docker Release / build-and-push (push) Successful in 4m21s
    Release / release (push) Successful in 1m29s
    CI / tests (push) Successful in 4m56s
    Pre-release

    k4wit0 released this 2026-09-01 12:19:13 +02:00 | 76 commits to main since this release

    Native status bar and a cleaner hero

    Added

    • The mascot is the project's icon. The browser tab, the installed app's icon,
      the logo on the README and the face on the sign-in and setup screens are now
      one drawing rather than four things that could drift.
    • scripts/icon.ts, which builds that icon out of the running application
      rather than from a second copy of the same curves.
    • A .dockerignore. Without one the whole working tree went to the daemon on
      every build — 286 MB down to 4.2 MB, measured, and more to the point a stray
      database in the tree is somebody's training and has no business inside an
      image that gets pushed to a registry.
    • A container image, built and pushed on every tag. :beta while there is no
      stable release; latest is only ever moved by a stable one, deliberately, so
      nobody pulling it lands on a prerelease. It is built without a signing key, so
      the binary inside refuses to update itself — updating a container is pulling a
      new image.
    • scripts/screenshots.ts, and the screenshots it takes. Never by hand: a
      screenshot taken by hand is whatever the window happened to be that afternoon.
      It refuses to run if the server is not answering, because otherwise it
      photographs the offline screen out of the service worker's cache and nothing
      fails.
    • .env.example, for the container path. Every variable in it is one the server
      actually reads, checked against the config package.
    • A test that everything the binary redistributes is named in NOTICE. Apache-2.0
      requires the attribution, and a NOTICE is exactly the kind of file that stops
      being true when a dependency arrives through another dependency.

    Changed

    • The README, CONTRIBUTING and SECURITY files follow the same shape as the
      other CodigoSH projects: the centred header with the logo, the badges and the
      screenshots; "How Development Works" and "Join the Team"; and a security
      policy that leads with how updates are delivered and ends with how to report
      something. The substance is this project's own — it signs its releases and
      says what its threat model does not cover — but the shape is the house's.

    • The README carries the house blocks verbatim: Support & Recognition with its
      three parts, and the signature line every CodigoSH repository ends with. Those
      are the organisation's, not this project's, and they now diff clean against
      Lastboard's. Its sections and badges line up one for one, and the picture at
      the top is a composed hero — the name and a headline down the left, the
      application on the right with rounded corners and a shadow — built by
      scripts/mockup.sh from the screenshots, at the same 3200×1800 as the other
      projects.

    • The README is a third shorter and stops repeating the documentation. What it
      said about the first-run token was two versions out of date — the token was
      removed — and what it said about the languages was one: all twenty are
      translated, and what fourteen of them are missing is the plural forms.

    • Screenshots are taken in English and dark, from an account seeded for the
      purpose and deleted afterwards. Pointed at an account seeded weeks earlier the
      home screen showed a line falling to zero and the calendar an empty month:
      both true, both a poor picture of what the application does.

    • deploy/compose.yaml is docker-compose.yml at the root, where the other
      projects keep theirs.

    • Releases are cut with CodigoSH's release script — Lastboard's, the same file
      in every project, with a CONFIGURATION block at the top for the handful of
      things that genuinely differ: which files carry the version, what the
      preflight runs, and where the notes come from. Everything below that block is
      byte-for-byte the same, so a fix made in one project is a file copied to the
      others.

    • scripts/release.sh beta "the headline" checks the tree is clean and level
      with the remote, runs the whole suite before anything is written, works out
      the next version, stamps it, writes the notes, tags and pushes — the tag
      last, because the tag is what CI builds from. --dry-run prints every
      decision and writes nothing.

    • The two halves of a release are two scripts. scripts/release.sh is the one
      a person runs; scripts/build-artefacts.sh is the one CI runs, and it is
      where the signing key is needed — a release cut from a laptop should not need
      one on that laptop.

    • The version lives in package.json and is stamped there by the release
      script, the way it is in Lastboard.

    Fixed

    • The status bar of the installed application is the colour of the canvas
      instead of the accent. On a phone it was painting an orange band across the
      top of a dark screen, which is a browser announcing itself in the one place
      an installed application is supposed to look like it owns the whole screen.
      It follows light and dark on its own with no script, and a theme chosen by
      hand beats the one the phone is set to.
    • Scrollbars are gone on a touch screen. They were a desktop affordance
      inherited whole, and no application on a phone draws one.
    • The screenshots are taken with touch emulation on. Setting a phone-sized
      viewport does not change the input device, so the pictures were still being
      rendered as though a mouse were pointing at them — the scrollbar came back in
      them while being correctly absent on a real phone. The script now refuses to
      run if the page reports a fine pointer.
    • The two phones on the README's hero image are placed by their screen rather
      than by their shadow. The blur spills out on every side, so compositing the
      file at a coordinate put the shadow there and the phone somewhere else, which
      is what pushed the second phone against the edge of the picture and made it
      look cropped. They have a bezel now, and the feature pills are spaced from
      their own measured widths instead of from three numbers written by hand.
    Downloads
  • v1.0.0-Beta.02 24ba9f7d3e

    v1.0.0-Beta.02
    All checks were successful
    CI / check (push) Successful in 1m13s
    Release / release (push) Successful in 1m20s
    CI / tests (push) Successful in 3m20s
    Pre-release

    k4wit0 released this 2026-09-01 05:31:14 +02:00 | 89 commits to main since this release

    LastGYM v1.0.0-Beta.02

    Setting up a server is one screen: a username, a password, and that
    account is the administrator. No token to copy out of a console.

    The rest is what the first release found the moment it was installed on a
    machine that was not mine — an updater that could not write its own new
    binary, an installer that asked questions nobody could see, and an install
    command in the README pointing at a URL that had never been fetched.

    Artefacts are signed. The updater checks the signature before it replaces
    anything, and you can check it yourself with sha256sum against SHA256SUMS.

    Downloads
  • v1.0.0-Beta.01 25bd596601

    v1.0.0-Beta.01
    Some checks failed
    CI / check (push) Successful in 1m44s
    Release / release (push) Successful in 2m5s
    CI / tests (push) Failing after 3m33s
    Pre-release

    k4wit0 released this 2026-09-01 04:20:17 +02:00 | 97 commits to main since this release

    LastGYM v1.0.0-Beta.01

    The first release. Every feature the 1.0 will have is built; what happens
    now is real use, the bugs that only real use finds, and the plural forms
    for fourteen more languages.

    Measured on five years of seeded training: 14 MB of binary, 16 MB resident
    at rest, 28 ms for the progress screen, 1.6 s for a first synchronisation.

    Artefacts are signed. The updater checks the signature before it replaces
    anything, and you can check it yourself with sha256sum against SHA256SUMS.

    Downloads