Server: website, logbook and API¶
| Rails | |
|---|---|
| Status | ✅ |
| Code | apps/server/ (config/routes.rb, app/controllers/, app/views/, app/javascript/controllers/) |
Verified against code: 2026-10-11
Purpose¶
apps/server is one Rails app that serves four things:
- The public website (marketing pages, watch check, help, beta sign-up).
- The signed-in logbook under
/my(logbook, trips, tracks, boats, devices, account) plus/pair. - The device API under
/api/v1that watches use to pair and upload tracks. - The admin area under
/admin(dashboard, support inbox, beta applicants, users, devices, quarantined segments, failed jobs), for users withadmin.
Stack¶
| Part | Choice |
|---|---|
| Framework | Ruby 3.4, Rails 8 |
| Database | PostgreSQL with PostGIS |
| Front end | Server-rendered ERB, Hotwire (Turbo Drive, Turbo Frames, Turbo Streams with morph refresh), Stimulus via importmap. No build step, no CSS framework. |
| Styles | Plain CSS with tokens (app/assets/stylesheets/tokens.css, base.css, components/*.css), served by Propshaft. Light and dark mode from prefers-color-scheme. |
| Maps | Leaflet 1.9.4 and protomaps-leaflet 5.1.0 (from unpkg, only in the layouts with maps: signed-in, public trip, track editor), own vector tiles from tiles.sailever.com (Protomaps extract in Cloudflare R2), EMODnet depth layer, OpenSeaMap seamark overlay (maps.md). The scripts are not on every layout, so a Turbo visit can show a map before they have run: map controllers wait for them (maps/leaflet_ready.js, up to 10 s) instead of drawing nothing. |
| Background jobs | Solid Queue on the worker VM, queues realtime (uploads, track processing) before default before background (FitBatchImportJob, TrackRecomputeJob, TrackSegmentIngestJob, UnattachedBlobCleanupJob, DatabaseBackupJob, …; apps/server/DEPLOYMENT.md "Jobs") |
| Link preview images | Headless Chromium (Debian chromium, ferrum gem) in the jobs role draws shared trips' preview images (sharing.md, ADR 014) |
| Files | Active Storage (archived FIT files, link preview images, upload blobs); production: private OCI Object Storage bucket sailever-files. Files are uploaded through the app, never straight to the bucket. |
| Files in development | local disk; mise server --production-files (run by the owner: it reads the OCI key from 1Password) starts the app with the local_with_production service (lib/active_storage/service/read_through_service.rb): a file missing on the local disk is read from sailever-files, so a pulled production database shows its tracks and images. Writes and deletes only ever touch the local disk. |
| User sign-in | Passkeys (WebAuthn, webauthn gem) and magic links. No passwords. See auth.md. |
| Admin access | Ordinary users with users.admin, same sign-in; others get 404. See admin.md. |
Action Mailer, ERB templates, sender SailEver <no-reply@sailever.com>; production sends over SMTP through OCI Email Delivery. The logo comes from assets.sailever.com (public Cloudflare R2 bucket sailever-assets, infra/assets). See emails.md. |
|
| Backups | Nightly age-encrypted pg_dump of the main database to the write-only bucket sailever-backups (DatabaseBackupJob, 3:00 UTC); see DEPLOYMENT.md |
Spec files¶
| File | Covers |
|---|---|
| public-site.md | Every public page, header, footer, watch check, films, support form, beta form |
| auth.md | Sign-in page, magic links, passkeys, sessions, account page (profile, email change, passkeys, subscription) |
| logbook.md | Signed-in navigation, logbook home (/my/logbook), trips list, trip cards, upload dialog, boats |
| trip.md | Trip page (map, numbers, days, tracks), night override, trip edit, GPX export |
| sharing.md | Public links, crew links, the crew section, the public trip page /t/<token> |
| profile.md | Public skipper profile skipper.sailever.com/<handle> (/s/<handle>): handle, settings, images, picked trips |
| track-editor.md | Track editor: trim IN/OUT, departure and destination names, move to another trip, delete |
| trip-replay.md | Trip replay: the trip on the map with the boat, instruments and speed histogram, for owner, crew and public links |
| maps.md | Map tiles: base map, tile host tiles.sailever.com, monthly update, costs |
| devices.md | Devices list, edit, unpair, and the /pair page |
| emails.md | Every email: trigger, subject, content, links |
| admin.md | Admin area |
| i18n.md | Languages and which parts are translated |
| api.md | Device API endpoints |
| ingestion.md | How SLTB segments and FIT files become tracks and trips, FIT upload pipeline |
| data-model.md | Tables, aggregates, days, bridges, night detection |
Shared contracts: SLTB track sync, device API and pairing.
URL map¶
All public and signed-in paths accept an optional locale prefix (/de/…, /fr/…; see i18n.md). /admin and /api have no locale prefix.
Public (marketing layout)¶
| Path | Page | Spec |
|---|---|---|
/ |
Home | public-site.md |
/watchapp |
WatchApp | public-site.md |
/datafield |
DataField | public-site.md |
/logbook |
Logbook (marketing) | public-site.md |
/watches |
Supported watches | public-site.md |
/help |
Install guides, FAQ, support form | public-site.md |
/about |
About | public-site.md |
/beta (GET, POST) |
Join the beta | public-site.md |
/support_requests (POST), /support_requests/sent |
Send support form, "Message sent" page | public-site.md |
/imprint, /privacy, /terms |
Legal pages | public-site.md |
/supported-devices |
Redirect to /watches |
|
/contact |
Redirect to /help#support |
|
/up |
Health check (200 when the app boots) |
Sign-in (auth layout)¶
| Path | Purpose | Spec |
|---|---|---|
/auth/magic_link (GET, POST) |
Sign-in page; request a magic link | auth.md |
/auth/magic_link/callback?token= |
Magic link target | auth.md |
/auth/passkey/options, /auth/passkey (POST, JSON) |
Passkey sign-in | auth.md |
/auth/logout (DELETE) |
Sign out | auth.md |
/pair (GET, POST) |
Pair a watch with the 6-digit code | devices.md |
/pair/apply (POST) |
Old beta form target, same as POST /beta |
|
/t/:token |
Public trip page (read-only, no sign-in) | sharing.md |
/s/:handle, /s/:handle/trips/:id with its replay and replay points; on the skipper host /:handle/… |
Public skipper profile and its picked trips | profile.md |
/my/profile (PATCH), /my/profile/images/:kind(/original), /my/trips/:id/profile_pick |
Profile settings, images, "Show on my public profile" | profile.md |
/t/:token/replay, /t/:token/replay/points.json |
Trip replay of a shared trip | trip-replay.md |
/crew/:token (GET, POST) |
Crew link: confirm and join | sharing.md |
Signed in (application layout, everything under /my)¶
| Path | Page | Spec |
|---|---|---|
/my/logbook (/my forwards there) |
Logbook | logbook.md |
/my/trips |
Trips list | logbook.md |
/my/trips/upload (POST) |
Upload dialog steps (preview, commit) | logbook.md |
/my/trips/:id (?view=tracks) |
Trip page | trip.md |
/my/trips/:id/edit |
Edit trip | trip.md |
/my/trips/:id/export.gpx |
GPX download | trip.md |
/my/trips/:id/replay, /my/trips/:id/replay/points.json |
Trip replay and its data | trip-replay.md |
/my/trips/:trip_id/days/:id (PATCH) |
Night override | trip.md |
/my/trips/:trip_id/links/open (POST, kind=public / crew) |
Open the link dialog: the working link, or a new one when none works | sharing.md |
/my/trips/:trip_id/links (POST, kind=public / crew) |
Create a public link or a new crew link | sharing.md |
/my/trips/:id/links/public (DELETE) |
Stop sharing (end all public links) | sharing.md |
/my/trips/:trip_id/crew_memberships/:id (DELETE) |
Remove from the crew / leave | sharing.md |
/my/tracks/:id/edit |
Track editor | track-editor.md |
/my/tracks/:id/points |
The track file for the track editor | track-editor.md |
/my/boats, /my/boats/:id/edit |
Boats | logbook.md |
/my/devices, /my/devices/:id/edit |
Devices | devices.md |
/my/profile, /my/account, /my/subscription |
Profile, Account and Subscription tabs | auth.md |
/my/account/delete, /my/account (DELETE) |
Delete the account: confirmation page, then erasure | auth.md |
/my/privacy-consent (GET, POST) |
Agree to the current privacy policy (every other /my page redirects here until then) |
auth.md |
/my/account/email/confirm?token= |
Confirm a new email address (works signed out) | auth.md |
/my/passkeys/options, /my/passkeys (POST), /my/passkeys/:id (DELETE) |
Add and remove passkeys | auth.md |
Every /my page needs a signed-in user. Signed out, it redirects to the sign-in page with redirect_to=<the page> and the alert "Please sign in to continue." JSON requests get 401 {"error":"Unauthorized"}.
A record of another user always answers 404 (every lookup goes through current_user).
Admin (admin layout, English only)¶
| Path | Page | Spec |
|---|---|---|
/admin |
Dashboard (admin home) | admin.md |
/admin/support_requests, /admin/support_requests/:id |
Support requests | admin.md |
/admin/beta_applicants, /admin/beta_applicants/:id |
Beta applicants, applicant page | admin.md |
/admin/accounts, /admin/accounts/:id (DELETE) |
Users, account erasure | admin.md |
/admin/devices, /admin/devices/:id |
Registered devices | admin.md |
/admin/track_segments |
Quarantined SLTB segments | admin.md |
/admin/failed_jobs, /admin/failed_jobs/:id |
Failed jobs, retry, discard | admin.md |
Every admin path needs an admin (admin.md § Sign-in). Old admin paths with a locale (/<locale>/admin…) redirect to the paths above.
API (no locale, bearer token)¶
/api/v1/devices/* and /api/v1/tracks/*: see api.md.
Hosts¶
One app answers on several host names (config/application.rb, app/constraints/host_constraint.rb). The names come from the environment, comma-separated: SAILEVER_API_HOST (beta: api.sailever.com) and SAILEVER_WEB_HOSTS (beta: beta.sailever.com). A list that is not set matches any host, so development and tests answer on localhost.
| Paths | API host | Web hosts | Any other host |
|---|---|---|---|
/api/v1/* |
✅ | 404 | 404 |
Everything else above (public, sign-in, /my, /pair, /admin) |
404 | ✅ | 404 |
/up, /robots.txt |
✅ | ✅ | ✅ |
- In production, Rails also refuses any
Hostheader that is neither the API host nor a web host (config.hosts);/upis exempt, for the kamal-proxy health check. With no hosts set, every host is allowed. /robots.txtisUser-agent: */Disallow: /unlessSAILEVER_INDEXING=true; then it disallows nothing. No cookie.- Links in emails use the first web host (emails.md).
Page chrome¶
Layouts¶
| Layout | Used by | Contents |
|---|---|---|
marketing |
Public pages, beta, support request page | Skip link "Skip to content", public header, flash, page, footer |
auth |
Sign-in page, /pair |
Same header (public mode) and footer, page in a centred card |
application |
Everything under /my |
Header in app mode when signed in, flash, page in wrap c-app, footer. Loads Leaflet. |
admin |
Admin area | Own sidebar, on phones a bar with a menu button; see admin.md. noindex, nofollow. |
Every page sets <title> as "og:type, og:url, title, description, image with size and alt, twitter:card; default image films/wa-poster.jpg, see sharing.md), hreflang links for the 7 locales, and Turbo morph refresh with preserved scroll.
Header¶
| Mode | Left | Navigation | Right |
|---|---|---|---|
| Public (signed out) | Logo (34 px high) → / |
DataField · WatchApp · Logbook · Watches · About · Help | Watch chip, "Sign in" link, "Join the beta" button |
| Public (signed in) | Logo → /my |
Same | Watch chip, "My Logbook" button → /my |
| App (signed in, application layout) | Logo → /my |
Logbook · Trips · Boats · Devices, each with an icon | Account menu (see logbook.md) |
The active link has aria-current="page". In app mode a link is active for its controllers (Trips is also active on track and day pages).
When SAILEVER_BETA=true, only existing accounts can sign in (auth.md), and a small "Beta" badge (c-badge--beta, "Bêta" in French) sits right of the logo in every mode, with the tooltip "This is the SailEver beta" (web.meta.beta_badge, web.meta.beta_badge_title, all 7 languages). Otherwise there is no badge.
Mobile web¶
At 980 px and narrower the navigation collapses into two icons on the right, both on the top level: Account (user icon) and Menu (hamburger), the menu outermost. Each opens its own popover (components/_mobile_account, components/_mobile_menu, mobile_menu_controller.js); opening one closes the other. aria-expanded follows each popover; Escape, a tap outside or leaving the page closes it. The same in the admin area (admin.md).
- Menu: the header's links (the active one with
aria-current="page"), then buttons when there are any: signed out "Join the beta" (primary) and "Sign in"; signed in on the public site "My Logbook". - Account, signed in: the same items as the desktop account menu (logbook.md): the email address, "Account", "Subscription", "Admin" (admins only) and "Sign out". One partial (
components/_account_items) renders them in both places. - Account, signed out: a link to Sign in (
aria-label"Sign in"), no popover.
Footer¶
| Column | Links |
|---|---|
| Brand | Logo, "Sailing tools for your watch and your logbook. Made in Austria." |
| Products | WatchApp, DataField, Logbook |
| Help | Supported watches → /watches, Install guides → /help#install, FAQ → /help#faq, Support → /help#support |
| Company | About, Join the beta |
| Legal | Imprint, Privacy, Terms |
Bottom row: "©
Flash messages¶
Notices (green check) and alerts (red alert icon, role="alert") show under the header. Each has a close button.
Buttons¶
Every button gets its classes from ButtonHelper#button_classes(role, size:, icon_only:, extra:) (app/helpers/button_helper.rb), so the same job looks the same on every page. bin/quality fails on a literal c-btn-- modifier in a view (check button_classes). Simple link buttons can use btn_link(label, url, role:, icon:, icon_after:).
| Role | Classes | Used for | Allowed shapes |
|---|---|---|---|
:primary |
c-btn--primary (orange --action) |
The one main action of a page, section head, card head or dialog: Invite crew, Add track, All trips, Save | normal; size: :sm in dialogs, popovers, table rows, product cards, the admin; icon_only: true for the trips list's filter button on phones; size: :lg, icon_only: true (66 px, 30 px icon) for the trip map's Open the map button and the replay's Play button; icon_only: true with c-editor__tool (44 px) for the replay's End the replay button |
:secondary |
c-btn--secondary (outline) |
A second action next to a primary one; account menu entries; pagination | normal, :sm |
:quiet |
c-btn--quiet |
Cancel, Not now | normal |
:row |
c-btn--quiet c-btn--sm |
Actions in table rows and lists: Edit, Move | — |
:tool |
c-btn--quiet c-btn--icon |
Icon-only buttons (with aria-label) in page heads, on maps and in the editor: Share, Export, Edit trip |
normal, :sm in rows |
:text |
c-btn--text |
Link-like actions: Back, Reset filter, "More" links | normal, :sm |
:danger |
c-btn--danger |
Delete and remove for good | normal, :sm, :sm icon-only |
:hero |
c-btn--primary c-btn--hero |
The trip page's Edit track tile only | — |
An unknown role or a shape the table doesn't allow raises ArgumentError, so a new look needs a change here first.
Shared formats¶
These rules hold on every signed-in page and in the upload dialog.
| Value | Format | Example | Helper |
|---|---|---|---|
| Distance | One decimal, unit "NM" | 42.1 NM |
LogbookHelper#nm |
| Duration | <h> h <mm> min |
5 h 30 min |
LogbookHelper#duration |
| Clock time | 24 h HH:MM in the trip's time zone |
08:40 |
LogbookHelper#clock |
| Date range | Repeats only what differs | 12–18 Sep 2026, 28 Sep – 3 Oct 2026, 30 Dec 2025 – 2 Jan 2026 |
LogbookHelper#trip_dates |
| Day / track date | %a %-d.%-m. |
Sat 12.9. |
Exception: trip cards show hours as 5.5h (see logbook.md), and emails show raw numbers (see emails.md).
Help texts¶
Help texts are never written out in the UI. A short explanation of a field, a button or a number sits behind a (?) (components/_help: a 20 px round button with an aria-label, its text in a tooltip that opens on hover, focus or tap and closes when the pointer or focus leaves or on a tap elsewhere). Checkbox hints use it through shared/_check_field.
One look everywhere: the component sets its own size, border, font and colours, and never sits inside a <label> (it would take the label's style, and a tap would focus the field). Next to a field label it goes in a c-label-row with the <label> around the text only.
Exception: a hero button may carry its hint on the button itself (the trip page's Edit track, trip.md § Numbers).
Stays written out: status and data (dates, "Waiting for confirmation…", "joined legal: true).
Data colours¶
The vessel-state colour tokens (--sail, --engine, --motorsail, --rest, light and dark) are defined once in vessel-state.md § Server colours. On the website:
--sailand--enginecolour map lines, the split bar, readouts and the SOG strip. Motorsailing is drawn as engine.- Map lines are set in JavaScript with the light values in both themes. Bridge lines use the
--restlight value. Line styles: trip.md § Map and track-editor.md § Map (the track editor takes the theme's values). - Bridged distance in the split bar uses
--line. - Signal orange (
--signal) is only for the logo and primary actions. - Email colours: emails.md.
Known deviations¶
AGENTS.mdand older docs name the engine colour#ef4444. The website uses the--enginetoken; only the trip email uses#ef4444(emails.md).