- Swift 95.9%
- Shell 2.3%
- TypeScript 1.3%
- Python 0.5%
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 |
||
|---|---|---|
| .argent/flows | ||
| .impeccable/critique | ||
| abc-widget | ||
| abc-widget.xcodeproj | ||
| abc-widgetTests | ||
| AppIcon.icon | ||
| docs | ||
| goldie | ||
| screenshots | ||
| Shared | ||
| watch | ||
| widget | ||
| .gitignore | ||
| DESIGN.md | ||
| LICENSE | ||
| PRODUCT.md | ||
| README.md | ||
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 |
|---|---|---|
![]() |
![]() |
![]() |
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:
- Signing team on all four targets.
- Bundle identifiers —
fr.phormian.abc-widgetand its.widgetand.watchkitappsiblings. - The app group,
group.fr.phormian.abc-widget, inAppConfig.swiftand in both.entitlementsfiles. 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.


