Skip to content

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:

  1. The public website (marketing pages, watch check, help, beta sign-up).
  2. The signed-in logbook under /my (logbook, trips, tracks, boats, devices, account) plus /pair.
  3. The device API under /api/v1 that watches use to pair and upload tracks.
  4. The admin area under /admin (dashboard, support inbox, beta applicants, users, devices, quarantined segments, failed jobs), for users with admin.

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.
Email 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 Host header that is neither the API host nor a web host (config.hosts); /up is exempt, for the kamal-proxy health check. With no hosts set, every host is allowed.
  • /robots.txt is User-agent: * / Disallow: / unless SAILEVER_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 " · SailEver", a meta description (default: "Sailing tools for your Garmin watch: a free DataField, the SailEver WatchApp that records every trip, and an online logbook that fills itself."), Open Graph tags (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.

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.
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: "© SailEver · Jakob Sommerhuber" and the language links EN DE FR IT ES EL PT (current one marked).

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 ", expiry dates of links), error messages, and legal wording the user must see when agreeing (the privacy and terms checkboxes, 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:

  • --sail and --engine colour 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 --rest light 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.md and older docs name the engine colour #ef4444. The website uses the --engine token; only the trip email uses #ef4444 (emails.md).