diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..c35fdca --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,74 @@ +# Local Catch + +A yield and cost calculator for fishermen and fishmongers. It turns what a fish +costs whole into what each finished product really costs, and lets people keep +and share the yields they measure themselves. + +## Language + +### Yields and costs + +**Conversion**: +Going from one form of a fish to another, written `From → To` (e.g. +`Round → Skinless Fillet`). +_Avoid_: cut, transformation + +**Yield**: +The share of weight kept by a conversion, as a percentage above 0 and up to 100. +_Avoid_: recovery rate, fraction (0.42) + +**Reference yield**: +A published yield from the MAB-37 research that ships with the app. +_Avoid_: default yield, static data + +**Custom yield**: +A yield a person measured from their own processing. Private unless approved +into the community dataset. +_Avoid_: user data, my data + +**Saved calculation**: +A cost or weight calculation a person chose to keep. +_Avoid_: calc, saved calc + +### People and the community dataset + +**Account**: +An optional sign-in that exists for two reasons only: keeping a person's custom +yields and saved calculations across devices, and submitting custom yields to +the community dataset. A reviewer's account can also review submitted yields. +_Avoid_: user, profile + +**Guest**: +Someone using the app without signing in. A guest can save, but loses what they +saved if they clear their browser data, and cannot submit yields. +_Avoid_: anonymous user + +**Submitted yield**: +A custom yield its owner has asked to add to the community dataset. It stays +private until a reviewer approves it. A rejected yield goes back to private, +where its owner can fix it and submit it again. Editing an approved yield takes +it out of the dataset and submits it again. +_Avoid_: shared yield, published yield, contribution + +**Review note**: +An optional message from the reviewer to a yield's owner saying why it was +rejected. Only the owner sees it. +_Avoid_: rejection reason, feedback + +**Reviewer**: +An account that has been granted the right to approve or reject submitted +yields. Being signed in is not enough. +_Avoid_: admin, moderator + +**Community dataset**: +All approved yields, readable and downloadable by anyone under CC BY 4.0, +attributed only by display name or as "Anonymous". +_Avoid_: community pool, public data + +**Display name**: +The only public identity a person has. Optional, chosen by them, never their +email. +_Avoid_: username, contributor name + +_Dropped concepts_: contributor profile (bio/organization pages) and publishing +saved calculations publicly. Neither is one of an account's two jobs. diff --git a/docs/adr/0001-firebase-auth-and-firestore.md b/docs/adr/0001-firebase-auth-and-firestore.md new file mode 100644 index 0000000..c4ddf2c --- /dev/null +++ b/docs/adr/0001-firebase-auth-and-firestore.md @@ -0,0 +1,79 @@ +--- +status: accepted +--- + +# Firebase Auth, Firestore and Firebase Hosting, with no server of our own + +The app ran two backends (Express + SQLite locally, Vercel functions + Neon in +production), a shared handler layer, a custom offline sync engine and two +sign-in systems, for about two accounts whose only jobs are keeping custom +yields and saved calculations across devices and submitting custom yields. We +chose Firebase Auth plus Cloud Firestore, reached directly from the browser: +security rules decide who reads and writes what, and Firestore's persistent +offline cache (so offline edits survive a reload) replaces our sync engine. The +static app is served from Firebase Hosting, so the whole app lives with one +provider. Firebase Auth was already in use, so no one signs up again. + +## Considered Options + +- **Supabase** (Postgres, auth, row-level security): about as simple, but free + projects pause after a week idle, and it would change the sign-in system a + third time. +- **Keep Neon and delete the Express/SQLite backend** (the direction in + `AUDIT_REPORT.md` item 2.1): smallest change, but we'd still own API code, + token verification and a sync engine. +- **Firebase SQL Connect** (proved in `dataconnect/`): needs a paid Cloud SQL + instance; more than this app needs. + +## Consequences + +- `api/`, `server/`, `shared/`, SQLite, Neon, the legacy password login and the + custom sync layer go away, and so does the rule to change both backends. +- Excel/CSV import parses in the browser. The import and export format gains + separate starting-form and finished-product columns; a row without a starting + form imports with it blank, like the Neon copy. +- Security rules cannot hide single fields of a readable document, so approved + yields are copied into a separate public collection holding only the public + fields (species, conversion, yield, display name). The owner's user id, the + free-text source note, submission details and review note stay in the owner's + private document. The reviewer's approval writes both in one batch. Changing + the display name rewrites the name on all of that owner's public copies in one + batch. Editing or deleting an approved yield deletes its public copy in the + same batch, and the rules let an owner delete the public copy of their own + yield. +- Firestore's persistent cache keeps private data in the browser after sign-out, + so signing out first sends pending writes (or, if offline, asks whether to + wait or discard them) and then clears the local cache. +- Our sync engine's revision check goes away: when the same record is edited on + two devices while one is offline, the later write wins. We accept this because + the accounts belong to one person; the conflict screen is dropped. +- The community dataset can be downloaded as CSV/JSON from the Community page. A + stable file address for other tools comes with the versioned releases in + ADR 0004. +- The Firebase SDK makes the app download larger than today's REST calls. +- Local development and CI use the Firebase emulators. +- The address moves from `*.vercel.app` to `*.web.app`. Browser storage belongs + to an address, so a redirect cannot carry a guest's saved data or unsynced + edits across. Before the redirect is switched on, the last version on the old + address asks guests to sign in so their data reaches their account, and + finishes syncing signed-in users. A later release makes the old app read-only + too: it stops accepting new edits, still pushes what is pending, and shows a + banner about the move. An old copy that was offline through all this can't be + seen from the server, so the read-only app also lets anyone save their unsent + changes as a file, which the new app's import accepts. After a waiting period + for devices to come online, the old API stops accepting writes (it answers + them with an error), so nothing changes in Neon while it is copied. The copy + uses a written list that maps each Neon account holding data to its Firebase + user id, checked by hand (there are about two accounts); it stops if an + account with data is missing from the list, appears twice, or two accounts map + to one Firebase user. Neon custom yields record only the finished product, not + the starting form, so the copy leaves the starting form blank for the owner to + fill in. The dry run lists any yield that is not above 0 and up to 100; the + owner fixes those before the freeze, and the copy refuses to run while any + remain. After the copy has been checked, the old address keeps serving the + read-only app for six months, so an installed copy that comes back online + still gets the save-to-file option and a link to the new address; only then is + Vercel reduced to a redirect. Bookmarks follow the redirect; installed copies + of the app need to be installed again from the new address. PR previews come + from Firebase preview channels. +- Supersedes `docs/AUTH_MIGRATION_ROADMAP.md` (Better Auth + Cloudflare). diff --git a/docs/adr/0002-guests-use-anonymous-sign-in.md b/docs/adr/0002-guests-use-anonymous-sign-in.md new file mode 100644 index 0000000..daf77ea --- /dev/null +++ b/docs/adr/0002-guests-use-anonymous-sign-in.md @@ -0,0 +1,43 @@ +--- +status: accepted +--- + +# Guests use Firebase anonymous sign-in + +People should be able to save before deciding to sign up. Instead of keeping +guest data in the browser and copying it into an account with our own code, a +guest's first save signs them in anonymously; their data lives in Firestore +under the same rules as any account, and signing in with Google or email later +links to that same user so nothing is copied. People who only use the calculator +never get an account. Anonymous sign-in needs the network, so a save made +offline before the guest has a user is kept in the browser and written to +Firestore once sign-in succeeds. + +## Consequences + +- Guest data saved by the current app (in the browser, not Firestore) is not + read by the new app. It moves over the existing way before the switch: the + current app asks guests to sign in, its adoption code moves their records into + the account, and they reach Firestore with the one-time copy from Neon (see + ADR 0001). The old guest storage and adoption code are removed only after + that. + +- If the guest signs in with a Google account or email that already belongs to + an account, Firebase cannot link it to the anonymous user. In that case the + app reads the guest's custom yields and saved calculations while still signed + in as the guest, signs in to the existing account, and writes them there. This + copy is the one piece of guest-transfer code we keep. + +- A guest who clears browser data loses what they saved, as today. +- Unused anonymous accounts pile up and may need occasional cleanup. Deleting an + Auth user does not delete its Firestore documents, so cleanup removes the + user's custom yields and saved calculations along with the user. +- Anonymous users can write to Firestore without a verified account, so the app + uses App Check, and the rules cap the size and number of each user's records. + Those caps are per user, so the project-wide limits are App Check, Firebase + Auth's limit on new anonymous sign-ups from one IP address (kept low), and the + free plan's hard quotas, which stop writes instead of billing. A bot could + still exhaust the free quota and block saves until it resets; we accept that + for an app of this size and revisit it if it happens. +- Only a non-anonymous account can submit yields to the community dataset, so + every submitted yield has a real owner. diff --git a/docs/adr/0003-five-pages-inventory-is-separate.md b/docs/adr/0003-five-pages-inventory-is-separate.md new file mode 100644 index 0000000..3c2f0a6 --- /dev/null +++ b/docs/adr/0003-five-pages-inventory-is-separate.md @@ -0,0 +1,13 @@ +--- +status: accepted +--- + +# Five pages; inventory is a separate tool + +The app is only the calculator and what an account needs around it: +Calculator, My data (custom yields, saved calculations, import, submit), +Community (the community dataset and where reference yields come from), About, +and Review (reviewers only). Sign-in is a panel, not a page. Contributor +profiles, the roadmap page and the "Inventory — coming soon" placeholder are +removed. Inventory belongs in a separate tool in the harvester suite, not in +this app, so it should not be added back here. diff --git a/docs/adr/0004-community-dataset-cc-by.md b/docs/adr/0004-community-dataset-cc-by.md new file mode 100644 index 0000000..d864ff3 --- /dev/null +++ b/docs/adr/0004-community-dataset-cc-by.md @@ -0,0 +1,26 @@ +--- +status: accepted +--- + +# Community dataset is CC BY 4.0; download now, versioned releases later + +Submitting a yield means its owner agrees that, once approved, it is released +under CC BY 4.0 with attribution by display name or "Anonymous"; the submit +dialog says so. No other identifying field (such as the organization from the +old contributor profiles) is published or exported. The code stays MIT. A +license on contributed data cannot be narrowed after the fact, so this is set +before any submissions under the new flow. For now the Community page offers a +CSV/JSON download built from the approved yields. Every CSV export, including +the later scheduled one, neutralizes cells that start with `=`, `+`, `-` or `@` +so a spreadsheet does not run them as formulas. Scheduled, versioned releases (a +GitHub Action exporting a numbered file at a stable address other tools can +fetch) will be added once there are enough approved yields to be worth citing. +Until then the dataset has no fixed download address. + +## Consequences + +- Yields shared before this consent existed enter the community dataset only + with their owner's agreement to CC BY 4.0: the one-time copy from Neon marks + them approved only for owners who have agreed, and leaves the rest private for + their owners to submit again. A copied yield whose starting form is still + blank stays private until its owner fills it in and submits it.