# OpenDiving Web **A dive log built to outlive every vendor. Your dives, your data — vendor exports in, open formats out, everything in one click. Yours to self-host.** OpenDiving is an open-source logbook for scuba divers — recreational and technical: log dives with multi-tank gas mixtures (nitrox and trimix), import dives straight from your dive computer's export file — full depth/temperature/tank-pressure profile, heart rate, deco ceiling, the readouts the computer itself computed and dive events included — group them into trips, and keep your gear service history and c-cards in one place. Cloud dive logs come and go — Movescount, Deepblu, Diveboard — and when they go, years of dive history go with them. OpenDiving is built on a different premise: the app is AGPL-licensed, the data sits in a plain Postgres database, and every dive-computer file you upload stays with the recording it came from, downloadable at any time. Self-hosting isn't a feature here; it's the guarantee behind the rest — anyone can run this software, one click hands the whole log back in open formats, and it reads a logbook back in from DiveJSON, UDDF, Subsurface, FIT, a Suunto app or DM5 export, or a Shearwater Cloud database or XML export, so no shutdown, acquisition, or paywall can ever take your logbook with it. **This repository is the web app — one component of the stack.** The project itself, and everything about running it, lives at **[opendiving/opendiving](https://github.com/opendiving/opendiving)**: the install, the configuration reference, the operator guides and the release that ties the components together. Start there if you want to run OpenDiving rather than work on this half of it. It is also where the instance this project runs itself is named, for anyone who would rather not run one. ![Home](docs/screenshots/home.png) ## Features - **Dive logging** — times, depths, duration, the kind of dive, water and air temperature, visibility, water type, altitude, current, waves, weather, how you got in and the boat you dived from, weight, notes, your own rating out of five, and any number of gas mixtures (O₂/He, start/end pressures) per dive. File dives and dive sites under tags of your own words, then filter the log by a tag or by the kind of dive, or read it best-rated first. A dive can span multiple dive sites (drift dives happen), in order. Switch off the fields you never fill in and save the arrangement as a named set — the choice follows your account, not the device. - **Technical diving** — trimix and nitrox mixes get derived gas names and per-mix **MOD** at your ppO₂ limit, plus END/EAD; the profile chart shades the **deco ceiling**, plots the computer's own **NDL, TTS, ppO₂, CNS and gradient factors** and marks dive events; each recording says the mode it ran in, the salinity it was set to and the **decompression model** behind those numbers, and keeps its own **CNS/OTU** oxygen exposure and surface pressure from the import; per-cylinder ppO₂ limits and gas roles are kept too. - **Dive-computer import** — upload one dive's file in any format **logbook import** reads (below) and the form pre-fills itself, keeping the file's own UTC offset where it records one and falling back to your current timezone where it does not; a file holding several dives, or one file carrying two computers' records of a dive, goes through logbook import instead. Every file you upload stays with the **recording** it came from and can be re-downloaded any time — a dive logged off two computers has two recordings, and one computer's JSON beside its FIT is two files of one recording, each filling what the other left blank. The per-sample **dive profile** is extracted and charted per recording, with a switcher when there is more than one: depth, temperature, tank pressure and the deco ceiling on the depth plot; on a panel under it the **heart rate** the wrist sensor or strap measured, then the six readouts the computer itself computed — **no-deco time, time to surface, ppO₂, CNS and both gradient factors**; and the device's own event markers along the axis. The dive page states the heart rate's maximum and average. A computer that chopped one dive into two logs them as two dives, and **Merge** folds them back into one. - **Air consumption** — SAC and RMV are derived automatically, including a **per-tank breakdown** across recorded gas switches on multi-tank dives, with a consumption trend chart on the Home page. - **Trips** — group dives into a liveaboard or a holiday week, a part at a time: each part carries its own place and its own dates, and the trip spans them all. - **Dive sites** — one map and one search over your own sites and OpenDiving Commons, a shared pool of named sites: the sites nearest the map's centre until you type, then the sites and the places that match, each place with how many sites are near it. Your own sites record the other names a site goes by, the depths it is dived at, its water, its altitude and how you get in, and your own tags. Use a Commons site as it stands: every diver's dives name the same record, with your notes and tags on top and yours alone. Where the Commons is edited, propose a site of yours or a change to a record, for a reviewer to accept. A site's page sums up your dives there — how many, the last and the deepest, the species you saw and your average rating — beside every dive you've logged at it, and a dive logged there starts from its water, altitude and entry. Under _Your sites_, sort the list by name, by dives or by the last dive, and filter it by a tag. The dive form picks a site from the same map, opened on the dive's sites, its GPS position or its trip. - **Gear tracking** — your equipment with per-item dive counts and dive time, groupable into gear sets you can attach to a dive in one click; a cylinder records its volume, working pressure and material once, and a dive's tank links it. Plus **service schedules** (annual service, visual inspection, hydro test…) by months, dives or hours of diving, with due-soon reminders under the notifications bell and by email. - **Marine life** — record what you saw against a real species catalog, resolved live against the World Register of Marine Species and Wikidata so a name you half-remember still finds the animal, with how many you counted or a band — few, many, abundant — and a note on each. Your **life list** collects every species you have ever logged, with a photograph fetched from Wikimedia Commons and served from the instance you are on — your browser never talks to Wikimedia, and each species has a page carrying its credit, its classification and the dives you saw it on. The taxonomy is WoRMS's, whose text content is available under [CC BY](https://creativecommons.org/licenses/by/4.0/) and which asks to be cited in full: _WoRMS Editorial Board (2026). World Register of Marine Species. Available from https://www.marinespecies.org at VLIZ. Accessed 2026-09-11. doi:10.14284/170_ — a date that stands for no copy of anything, because **this app queries the register live rather than holding a snapshot of it**. - **Certifications** — keep photos of your c-cards on hand at the dive shop without digging out the plastic, and an email when a card or one of your insurance policies comes within 90 days of expiring, and again when it does. - **Check-in** — the details a dive shop asks for at the desk, kept once: a portrait cropped like a passport photo, date of birth, phone, the email you choose to give out, which need not be the one you sign in with, your emergency contacts in the order to call them, and your insurance policies. One page gathers them with your c-cards, dive count and last dive, and prints — your browser's save-as-PDF is the copy for a desk with no signal — or shares as a link and a QR code the desk opens on its own phone, for a day or until you revoke it. - **Courses** — the training itself, with the agency, instructor and shop: link the dives you did on it and the cards it issued, so a course is one record instead of a shape you have to remember. - **Contacts** — the dive centers, schools, shops, clubs and places you stayed, each kept once with its phone, email, website, address and place on the map, and picked from a list for a dive, a course, a card, a gear service or a trip part. A dive logged on a course takes its dive center from it, and a trip names its dive centers from its dives. - **People** — your buddies, guides, instructors, fellow students and whoever came along, each kept once and picked for a dive, a trip or a course with the role they had that time; a course's and a card's instructor is one of them. Type a name that is new and it becomes a person. Link one to their account on the same instance by its username and you see that account's current `@username` beside the name, nothing else of theirs, and they are not told. UDDF and Subsurface buddies arrive as people when you import. - **Full export** — one click to take _everything_ out in open formats: a **DiveJSON** document holding the whole logbook, a **UDDF** one other programs import, a **CSV** for a spreadsheet, or a complete **archive** carrying all three alongside every dive-computer file you uploaded, every c-card image you stored, and your profile picture and portrait. [DiveJSON](https://divejson.org) is the open dive-log interchange format this project maintains, and this app is its reference implementation. A data-ownership log without an exit door is a contradiction. - **Logbook import** — and a door that only opens outwards is half a promise, so a whole logbook reads straight back in. One **Import** page takes any number of files at once, in any mix: the **DiveJSON** document and the **archive**, and — through the [DiveJSON converter](https://github.com/divejson/divejson-py) — **UDDF**, a **Subsurface** `.ssrf` save file, **FIT**, a **Suunto app** export, a **Suunto DM5** `.xml`, and **Shearwater Cloud**'s whole-database `.db` and one-dive `.xml` exports, or a `.zip` of any of them, which is how a watch's account export arrives. Drop a folder and the app sorts it: a computer's two exports of one dive become one dive with both files, and a converted file that is one dive is kept on the dive it becomes, as it is when uploaded to a dive; a logbook of several dives, or a DiveJSON document, arrives without its file. Move a logbook between instances, restore one from a backup, or bring years of history out of something else. The files go up once and the server holds them, so an import started on one device can be finished on another. The review is dive cards of what is new and what changes, each opening the dive or what it was and what it becomes, with any file that could not be read listed first; nothing is written until you import. Each import stays as history, with a log of what its files did not record, what the import worked out, and which of the dive's values it left out. Records you already have are matched rather than duplicated, and a dive you deleted stays deleted. The archive additionally restores the dive-computer files and c-card scans, which the bare document names by digest but does not carry. The check-in details a logbook carries, and an archive's portrait, are shown beside yours in the review, and only what you choose there is saved. - **Year in review** — each January, an email with the year before in figures: dives and time underwater, the deepest and longest, dive sites, and species with the ones you saw for the first time. - **Passwordless sign-in** — email magic links, a six-digit code, passkeys, or Google. No passwords stored, ever. - **Dark mode & responsive** — works on the boat, in the dive shop, and on your desk. - **Installs like an app** — add it to a phone's home screen from the browser's install or share menu (on iOS 16.4 and later, Safari's Share → Add to Home Screen), and it opens in its own window on your Home page. | | | | ------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ![A dive, with the profile charted from its dive-computer recording](docs/screenshots/dive-detail.png) | ![A gear item with its service schedule and history](docs/screenshots/gear-item.png)
![A dive site on the map, with every dive logged there beside it](docs/screenshots/dive-site.png) | ## Planned Roadmap items, roughly in priority order — contributions welcome: - **More importers** — UDDF, Subsurface, FIT, Suunto's own two — the app's JSON and DM5's XML — and Shearwater Cloud's two — its whole-database export and its one-dive XML — already import and fill the dive form, both through the DiveJSON converter (a Shearwater Cloud database of several dives goes through the Import page, the form taking one dive), and a new format is a reader there, for both, rather than a change here. The iOS app downloads from Shearwater and Suunto Ocean computers over Bluetooth, through [libdivecomputer](https://www.libdivecomputer.org/), and the logbook import takes what it brings, keeping each dive's original download beside it. - **Statistics** — depth/time records, dives per year, sites map. - **Sharing** — public link to a dive or trip. - **iOS companion app** — in development, not yet installable ([opendiving-ios](https://github.com/opendiving/opendiving-ios)). ## How it compares Honest answers to "why not X": - **[Subsurface](https://subsurface-divelog.org/)** — the open-source reference, with unmatched dive-computer support and a full deco planner. It's desktop-first with no web app or self-hostable server; OpenDiving is the server-shaped complement — a modern web UI, API-first, reachable from any browser, and self-hostable on a box of your own. Use Subsurface to download from cables and import its save file or its UDDF export here, so both can hold the same log. - **[Submersion](https://submersion.app/) / [Bubbletrail](https://bubbletrail.app/)** — excellent newer open-source _apps_: local-first, on-device databases, Bluetooth downloads. OpenDiving is the server-shaped alternative: one instance behind every browser and every family member, with an API — and yours to run on the household server if that is where you want it. Its own iOS app downloads over Bluetooth too, and hands the dives to that server rather than keeping them. - **Vendor clouds (Shearwater, Garmin, Suunto, Oceanic+)** — where dives are born, not where they should live. OpenDiving imports their exports — any number at once, each file of one dive kept on the recording it came from — so switching computers never splits your history, and a dive logged off two of them keeps both computers' records side by side. ## Self-hosting One compose file brings up the whole stack — this app, the API and its worker, Postgres, Redis, and a Caddy that provisions TLS for your domain. That file is not in this repository and neither are the instructions for it: an install is a product-level thing, so it is driven from **[opendiving/opendiving](https://github.com/opendiving/opendiving)** — the four commands, the six values in the `.env` that matter, and the upgrade are all on that page. What this repository contributes to it is one image. `ghcr.io/opendiving/opendiving-web` is prebuilt for amd64 and arm64, so a Raspberry Pi runs the same bytes as a VPS and there is no build step and no Node on the host; it is pulled alongside the API's by that compose file. **[Full self-hosting docs](https://github.com/opendiving/opendiving/tree/main/docs)** — install, every configuration variable, running behind your own reverse proxy instead of the bundled Caddy, backup and restore, upgrades, and troubleshooting. They live in one place rather than half here and half there. ### Building the image yourself The published image is what a `docker build` in this repository produces, so a fork or a local change is one build away: ```bash docker build -t opendiving-web . docker run -p 3000:3000 -e API_INTERNAL_URL=http://your-api-host:8000 opendiving-web ``` Nothing about your instance is baked into that image. The browser calls `/api/v1` on whatever origin served the page, and this app's own route handler forwards each request to `API_INTERNAL_URL` — an origin with no `/api/v1` on the end, read fresh on every request. Its default is `http://api:8000`, the API's service name on a compose network, so a container swapped into the shipped bundle needs the variable no more than the published image does. The rest of the settings are read on the server at request time too, so they are plain environment variables on the container: [`.env.example`](.env.example) documents each one. The image carries its own `HEALTHCHECK` against `/healthz`, which reports that this process is serving HTTP and deliberately nothing more — the web container talks to neither Postgres nor Redis, and the API owns readiness. Two settings are worth knowing when you are the one deciding how this is served rather than taking the bundle's answer: `WEB_HSTS=off` hands `Strict-Transport-Security` to a proxy in front, or drops it for a plain-HTTP LAN address, and `WEB_NOINDEX=true` keeps an instance that is reachable but private out of search engines. The same image carries a second program, the map renderer, which draws the map tiles the API stores once for every account and the dive and trip cards and the dive, trip and dive site page heads are composed from. It is started with its own command and reached by the API alone: ```bash docker run -e SITE_URL=https://dives.example.com opendiving-web node map-renderer/index.mjs ``` It reads the same basemap settings as the web server, answers the same `/healthz` — there, whether it can draw — and has its own section in [`.env.example`](.env.example). The single exception to runtime configuration is `NEXT_PUBLIC_API_URL`, for a split-origin deployment where the API answers on a host of its own and the browser should reach it directly instead of through this app: ```bash docker build --build-arg NEXT_PUBLIC_API_URL=https://api.example.com/api/v1 -t opendiving-web . ``` It is the full base with the `/api/v1` prefix included, and `NEXT_PUBLIC_*` values are inlined into the client bundle by the compiler — so that address is fixed at build time and changing it means rebuilding, which is exactly why it is no longer the default path. It is also one of the two things the CSP's `connect-src` is derived from — the other is the basemap, whose host comes from the runtime settings `.env.example` documents — so an API or basemap host reached any other way is blocked rather than merely misconfigured. Left unset, none of this applies. ## Development setup The web app is the frontend for [opendiving-api](https://github.com/opendiving/opendiving-api) — start that first (one `docker compose up`), then: ```bash npm install cp .env.example .env # points at http://localhost:8000/api/v1 by default npm run dev ``` Open [http://localhost:3000](http://localhost:3000), sign in with your email, and log your first dive. Without a configured email provider on the API side, the magic link is printed to the API logs — handy for local development. ### Scripts | Command | | | ----------------------------- | --------------------------------------------------- | | `npm run dev` | Development server | | `npm run build` / `npm start` | Production build / serve | | `npm run lint` | ESLint | | `npm run type-check` | TypeScript | | `npm test` | Unit tests (`test:watch`, `test:coverage` variants) | ## Tech stack Next.js (App Router) + TypeScript, Tailwind CSS, shadcn/ui with Base UI components, react-hook-form + Zod validation, axios. The dive-profile and consumption charts are hand-rolled SVG — no charting library. ## Contributing Issues and PRs are welcome — from a typo fix to a new importer. Open an issue first for bigger features so we can agree on the shape. See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, the checks CI runs, and the house rules, and [DECISIONS.md](DECISIONS.md) for the non-obvious choices already made. Security problems go through [SECURITY.md](SECURITY.md) rather than the issue tracker. ## Related repositories | | | | -------------------------------------------------------------- | ------------------------------------------------------- | | [opendiving](https://github.com/opendiving/opendiving) | The product: install bundle, operator docs, the release | | [opendiving-api](https://github.com/opendiving/opendiving-api) | FastAPI backend (Postgres, Redis, dive-file parsing) | | [opendiving-ios](https://github.com/opendiving/opendiving-ios) | SwiftUI app (in development, not yet installable) | ## License [AGPL-3.0](LICENSE). In short: run it, change it, self-host it freely — but if you offer a modified version as a service, you share your changes. Nobody gets to take this closed-source and lock divers' data away. Third-party artwork and vendored assets that travel in this tree — Google's sign-in mark, the MapLibre build, the OpenFreeMap styles — are credited in [NOTICE.md](NOTICE.md). The brand mark isn't among them: it's original to this project.