No description
  • Swift 95.9%
  • Shell 2.3%
  • TypeScript 1.3%
  • Python 0.5%
Find a file
Nicolas Perraut a61026cb6b Tell an invalid roster apart from an unreachable server (#1)
Backport of the Android API fixes (driver-android #2, #3).

- `ShiftServiceError.invalid(detail:)`: a 200 that is not a roster (login page, web front at the wrong address, drifted shape) is reported with the decoder's reason instead of reading like being offline. Banner: "Le serveur répond, mais pas avec une feuille de service." with the settings offered; the connection test now decodes the answer, so a wrong address is caught where it is typed.
- The day's notice and documents fail silently at the service level: a server with only the shift endpoint reads as "nothing to show".
- `ShiftServiceTests`: HTML → invalid, drifted shape names the missing field, a real roster decodes, a dead host is unreachable.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #1
2026-09-09 12:58:06 +00:00
.argent/flows Rebuild the App Store frames with goldie 2026-09-06 21:27:31 +02:00
.impeccable/critique Add a landscape view of the run under way 2026-07-30 20:41:42 +02:00
abc-widget Tell an invalid roster apart from an unreachable server (#1) 2026-09-09 12:58:06 +00:00
abc-widget.xcodeproj Give the widget extension the app's name 2026-08-11 08:53:07 +02:00
abc-widgetTests Tell an invalid roster apart from an unreachable server (#1) 2026-09-09 12:58:06 +00:00
AppIcon.icon Add vehicle info, segment comments, and per-day shift lookup 2026-07-20 12:10:43 +02:00
docs Say what the Android client asks of the server 2026-08-15 15:31:29 +02:00
goldie Export the App Store frames at 1290x2796 2026-09-06 23:14:07 +02:00
screenshots Export the App Store frames at 1290x2796 2026-09-06 23:14:07 +02:00
Shared Tell an invalid roster apart from an unreachable server (#1) 2026-09-09 12:58:06 +00:00
watch Tell a rest day apart from an unpublished one 2026-08-02 13:19:39 +02:00
widget Let the driver point the app at their own server 2026-07-29 09:06:36 +02:00
.gitignore Rebuild the App Store frames with goldie 2026-09-06 21:27:31 +02:00
DESIGN.md Write down the product and design context 2026-07-29 09:29:27 +02:00
LICENSE Say what this app is and how it may be copied 2026-08-11 08:52:55 +02:00
PRODUCT.md Write down the product and design context 2026-07-29 09:29:27 +02:00
README.md Say what this app is and how it may be copied 2026-08-11 08:52:55 +02:00

ABC+

An iPhone and Apple Watch app that shows a bus driver their feuille de service: the run under way, the break and how much of it is left, the next departure and its girouette code. It reads a roster from a server you run yourself, and it buzzes before each reprise so a break can be taken without watching the clock.

It was written for one depot and one driver, then for the colleagues who asked for a copy. Nothing in it is generic transit software: it assumes a duty is a contiguous list of segments from prise de service to fin de service, and it shows that day and nothing else. The interface is in French.

Before the shift On a run On a break
Before the shift On a run On a break

What it does

  • The day, as it stands. The current segment with a live countdown, what comes next, and the whole duty as a timeline you can scroll. Past, current and next are decided by the clock, never by a tap.
  • Departure alerts. A notification before each reprise, with an optional sound on the phone and a haptic on the watch. This is the one thing the app will interrupt you for.
  • Home and lock screen widgets. Small, medium and large, plus the three accessory families for the lock screen.
  • A Live Activity for the whole duty, with the current segment and the next one. The server can drive it over APNs at each segment boundary, so it stays right while the app is closed.
  • The watch app, showing the same run with the same wording, and firing its own local notifications so the wrist buzzes even if the phone is silent or elsewhere.
  • Other days. A day switcher fetches any date the server knows about, and marks rest days and unpublished ones differently, because they are not the same thing.
  • Stale is said out loud. A failed refresh keeps the last roster on screen and admits it is old. An unconfigured install says so rather than drawing an empty, restful-looking day.
  • A couple of comforts on duty: keep the screen awake, and turn the phone sideways to get the current run alone at arm's-length size.

How it is put together

Target What lives there
abc-widget The iPhone app: day view, settings, Live Activity management, watch sync.
widget Widget extension: home and lock screen widgets, and the Live Activity views.
abc-widget Watch App The watchOS app and its alerts.
Shared/ Model, API client, cache, palette, and the shared views. Files are added to each target explicitly.
abc-widgetTests Swift Testing tests for the roster logic (~30 of them).

The rule the code keeps coming back to: one fact renders the same way everywhere. A run's direction is drawn by RouteLabel in the app, the widget, the Live Activity and the watch, so a driver never has to re-read a layout because they looked at a different screen. DESIGN.md and PRODUCT.md say why the app looks and behaves the way it does; they are worth a read before changing anything visual.

You will need a server

The app has no backend in this repository, and it will not do anything useful without one. It expects an HTTP endpoint returning the driver's duty as JSON:

GET https://your-server/api/v1/shift
GET https://your-server/api/v1/shift?date=2026-08-11

The response is an ordered, contiguous array of segments — breaks included as explicit pause segments. The full contract, including the ActivityKit push payloads and the three date encodings APNs makes you juggle, is in docs/api-push-spec.md. Basic auth is optional and per-deployment.

How the roster gets into your server is your problem: the original one scrapes the depot's planning site overnight and serves the result. Anything that can produce the JSON will do.

Building it

Xcode 26 or later, an iPhone on iOS 26.5, a Watch on watchOS 11. Open abc-widget.xcodeproj, pick the abc-widget scheme, run.

Before it will build on a device, three things need to be yours rather than mine:

  1. Signing team on all four targets.
  2. Bundle identifiersfr.phormian.abc-widget and its .widget and .watchkitapp siblings.
  3. The app group, group.fr.phormian.abc-widget, in AppConfig.swift and in both .entitlements files. The app and the widget must agree on it: they share the server address, the keychain item and the cached roster through it, and if only one target has the capability the widget quietly keeps its own idea of where the roster comes from.

Push notifications need the capability registered too, and Live Activity tokens are only issued on a real device — never the simulator.

The watch target's project entries are hand-maintained. Xcode has been known to rewrite them when it adds files; if the watch app suddenly stops finding Shared/ sources, that is usually what happened.

Tests: ⌘U, or

xcodebuild test -project abc-widget.xcodeproj -scheme abc-widget \
  -destination 'platform=iOS Simulator,name=iPhone 17'

They run against a real roster captured from the API in July, embedded in ShiftFixture.swift and time-shifted so "now" lands before the duty, mid-run or mid-break. The same fixture drives the previews and the screenshots above.

First run

The app opens on a welcome screen asking for one thing: the address of your server. The path is fixed by the contract, so there is one field to mistype instead of four, and pasting a whole URL out of an email works as well as typing the host from memory. A missing scheme becomes https, since basic auth over plain HTTP would put the password on the wire in clear.

The password goes to the keychain, shared with the widget through the app group and readable after first unlock, so a widget timeline built while the phone is locked can still authenticate.

Sharing (optional)

There is a small section for sharing your shift with colleagues, over a protocol called Glong that the server implements. The app only drives /v1/glong on the same server the roster comes from — there is no crypto and no state here, and if the server answers 404 the section never appears. Installs without it will never know it exists.

Status

Feature complete for what one driver needs, which is the only bar it has been held to. Known edges: a free day currently comes back as a zero-duration marker rather than an empty array (the client normalizes it away), and there is no authentication beyond optional basic auth, because the server serves one driver.

If you run a depot's roster somewhere and want to point this at it, the spec is the place to start. Patches welcome, especially from anyone who has read a feuille de service in anger.

License

Copyright © 2026 Nicolas Perraut. Licensed under the GNU AGPL v3 or later.

Fork it, run it for your own depot, change whatever you like. If you hand a modified version to anyone else, or let people use one over a network, the source of your version has to be available to them under the same terms.

Two things the license does not cover: a server of your own that merely speaks the API in docs/api-push-spec.md is a separate program, not a derivative of this app — the spec is there to be implemented freely. And shipping an AGPL build through the App Store is a problem for redistributors, not for me: Apple's terms and section 6 don't sit well together, so a fork that wants to be on the Store should ask about a second license rather than assume.