GitHub - wjdavis5/lunarlog · GitHub
Skip to content
wjdavis5Public

About

No description, website, or topics provided.

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

1,148 Commits

Folders and files

Repository files navigation

lunarlog

Family cycle tracker built with Flutter: an account (Supabase Auth) syncs cycle profiles across a household's devices and lets guardians share a profile — co-parents, caregivers — each with their own role, invitation, and attribution (see "Family sharing" below); sync and multi-guardian collaboration are the product's priority and its differentiator, not an add-on. One adult operator manages the profiles (some are minors), and the account mirrors the local store into row-level-secured Postgres and syncs it offline-first across the operator's devices and to accepted guardians; nothing is uploaded until the operator signs in and, on a device that already holds data, explicitly consents to the upload. Entries are stored on the device encrypted at rest behind a biometric gate, and the app keeps logging, viewing, and predicting when the network doesn't — offline is a reliability property of the app, not its identity. Three parties receive data off-device, all only for app functionality: Supabase (the account email and the synced cycle rows), Sentry (crash reports reduced to an allowlist — no user, no health content), and Resend (transactional support email — an admin alert when you submit an in-app feedback ticket, and the reply email if the admin responds; never the ticket's message body or any attachment). Today the app tracks cycles and flow, estimates the next period, and estimates a fertile window and ovulation day from that same logged history (issue #143) — a calendar-method back-calculation only, with the same confidence tiering as the period estimate and no new user input; fertility-signal logging (BBT, cervical mucus, ovulation tests, issue #144) that would refine it further does not exist yet. Targets iOS (iPhone first-class) and Android. The web client is a separate React app in webapp/; the Flutter app itself no longer builds for the web. The app holds sensitive health data, including minors'; this repository is public, open source, and fully auditable, and must never contain real personal or health data.

Privacy Policy: Read our full Privacy Policy.
Security Policy: Read our Security Policy to report vulnerabilities responsibly.
License: PolyForm Noncommercial 1.0.0 (Free for personal/noncommercial use; commercial resale prohibited).

Status: pre-release. TestFlight builds ship from main (latest upload 2026-09-30); store submission is gated (see Known limitations).

Build / run

Flutter lives at C:\src\flutter on the lab desktop and is not on the persistent PATH — prepend it in each shell that needs it:

$env:Path = "C:\src\flutter\bin;$env:Path"

Then, from the repo root:

flutter pub get
flutter analyze                 # lints (flutter_lints)
flutter test                    # unit + widget tests
flutter run --dart-define-from-file=dart_defines.json   # with Supabase/Sentry configured
flutter build apk --debug       # Android debug APK
flutter build ios --release --no-codesign   # unsigned; requires macOS

Without --dart-defines the build has no account section, no sync, and no crash reporting — a development configuration for iterating without cloud credentials, not a supported product mode: the shipped app is built around an account, sync, and family sharing. See "Config & credentials".

Quality gates

dart run tool/quality_gate.dart     # 90% coverage floor + per-method CRAP gate (also runs in CI)
dart run tool/mutation_gate.dart    # mutation score for changed files (local only, no CI gate)
dart run tool/mutation_gate.dart --full   # every non-excluded lib/ file; no time budget

quality_gate.dart runs flutter test --coverage (or, with one or more --lcov <file> arguments, skips the run and merges those files — how CI evaluates the gates over its three parallel test shards), filters coverage/lcov.info through the reviewed exclusion list (tool/quality/exclusions.dart — generated code plus platform adapters that can't run under flutter test, e.g. PluginGoogleSignInClient), then checks total line coverage (floor 90%) and CRAP per method (comp² × (1 − cov/100)³ + comp, gate at 10), printing both reports either way. flutter test alone is unaffected and stays fast for iteration — the gates run only through this script (and the CI step that calls it).

mutation_gate.dart wraps the mutation_test package: by default it mutates only files changed against origin/main (including uncommitted changes); when every changed file has a directly mirrored test file (lib/a/b.dart → test/a/b_test.dart — true for most of lib/domain/), it scopes the test command to just those files, which is what keeps a typical run to roughly a minute or two. A change without a direct mirror (most lib/ui//lib/data/ files, tested through broader per-feature suites) falls back to the full test suite per mutant — slower, but never silently mis-scoped. Reports the mutation score and surviving mutants per file; nothing here gates CI, and the score isn't tracked automatically — note it in the PR description if it's worth recording.

Supabase local stack (database tests)

The remote schema, RLS policies, and the sync_push RPC live in supabase/migrations/ and are proven by pgTAP tests in supabase/tests/. The Supabase CLI is pinned to 2.116.0 and run through npx (not installed globally); Docker must be running.

npx supabase@2.116.0 start -x realtime,storage-api,imgproxy,mailpit,studio,edge-runtime,logflare,vector,supavisor
npx supabase@2.116.0 db reset --local    # re-apply migrations from scratch
npx supabase@2.116.0 test db --local     # 355 pgTAP tests
npx supabase@2.116.0 stop --no-backup

supabase/config.toml configures the local stack only (its auth settings — 6-character passwords, confirmations off, 1-hour JWT — are the CLI defaults and do not reflect the cloud project's dashboard settings; see docs/ops/supabase-go-live.md).

iOS build, sign & deploy (Mac)

On Williams-Mini (or any macOS build machine with Xcode and Apple Team ID 5273C9R3V4):

# 1. Fetch dependencies
flutter pub get

# 2. Build unsigned archive
flutter build ipa --release --no-codesign
# Or via xcodebuild:
xcodebuild -workspace ios/Runner.xcworkspace -scheme Runner -configuration Release -archivePath build/ios/archive/Runner.xcarchive archive CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO

# 3. Export signed IPA using ExportOptions.plist
xcodebuild -exportArchive -archivePath build/ios/archive/Runner.xcarchive -exportOptionsPlist ios/ExportOptions.plist -exportPath build/ios/ipa

# 4. Deploy to connected physical device
xcrun devicectl device install app --device <device-id> build/ios/ipa/lunarlog.ipa
# or via flutter:
flutter install -d <device-id>

The iOS target now carries ios/Runner/Runner.entitlements (Sign in with Apple), so any signing profile — local or CI — must be generated with that capability enabled on the com.wjdavis5.lunarlog App ID.

CI (.github/workflows/ci.yml) runs analyze + test on ubuntu, the pgTAP database tests against a local Supabase stack (db-tests job), a debug APK on ubuntu, and an unsigned iOS build on macOS, for pushes and PRs to main. Every build passes the Supabase, Sentry, and Google --dart-defines from repository secrets; on forks they resolve to empty and the build must still succeed.

Database migrations are pushed to the cloud project by .github/workflows/supabase-migrate.yml when supabase/** changes on main, inside the production GitHub environment (required reviewer). Automated iOS release to TestFlight and App Store review (primary) is handled by .github/workflows/ios-release.yml (mirrored from taxiGame). Automated Android release to Google Play is handled by .github/workflows/play-store-release.yml. The iOS release declares no non-exempt encryption usage to App Store Connect (the app compiles no third-party at-rest cipher; at-rest protection is the OS's own), paired with an App Store availability restricted to the US and Canada; see docs/ops/ios-export-compliance.md for the classification and the operator's recurring filing duties.

Accounts

The account (Supabase Auth) is what makes sync and family sharing work: signing in is how a device joins the household and how a profile gets shared with other guardians. It stays technically optional — an unconfigured build has no account section at all, and even in a configured build nothing uploads until the operator signs in and, on a device that already holds data, explicitly consents — but it is not an afterthought; it is the product's core loop. Sign-in methods: email/password, Google (iOS and Android, native picker), Apple (iOS), and passwordless email — a sign-in link opened on the requesting device or the code from the same email typed into the app. A signed-in operator can see which methods the account has in the account section, add Google (or Apple on iOS) to it after a fresh device-credential check, and remove Google or Apple the same way once the account holds another method — email/password is never removable, since it is the account's only recovery path. Sign-ups are open in the dashboard: lunarlog is a public store app, so anyone may download it and create an account (issues #800, #821, #971). The app shows "New accounts for this app are set up by the account owner" only when an operator closes sign-ups for a locked-down deployment. Passkeys are deferred (Supabase passkeys are beta and need an HTTPS relying-party domain the app does not yet have). The Google button is hidden in any build without both GOOGLE_* defines. A signed-in operator can delete the account outright (server rows, the account, an Apple revocation when applicable, then the device reset) — a fresh device-credential check runs first, the same as adding a sign-in method. Exporting a JSON copy of profiles and entries does not require that check: it sits behind the app's own lock, lives under Settings → Your data, and needs no account at all (server data is included in the export only when signed in); see PRIVACY.md for what each does. The same section also offers "Export clinical summary (FHIR)" (issue #157): a separate, IPS-shaped FHIR R4 document Bundle — self-reported cycle Observations, a Composition, a Patient carrying only the display name, and one Provenance record — built entirely on-device and delivered the same share-sheet way; see docs/clinical/fhir-export.md. The parent who created a minor's profile can also transfer ownership of it to the minor's own account (Issue #4): a "Transfer profile" action on the caregivers screen lets the parent pick their own post-transfer role (co-manager or read-only), then produces a single-use, 72-hour claim link the child opens on their own device to sign in or create an account and confirm. Every past entry stays exactly where it was, still labelled with who logged it; the parent's continued access afterward is an ordinary, revocable membership rather than custodial control. Plans: docs/plans/2026-09-02-001-feat-supabase-auth-cloud-sync-plan.md, docs/plans/2026-09-03-001-feat-social-logins-plan.md, docs/plans/2026-09-05-001-feat-remove-linked-sign-in-method-plan.md, docs/plans/2026-09-05-001-feat-account-deletion-and-json-export-plan.md, and docs/plans/2026-09-06-001-feat-child-ownership-transfer-plan.md.

Family sharing

A profile can be shared with other signed-in guardians via a single-use invite link (lunarlog://invite?code=..., 1-168 hour TTL), with roles primary_guardian, co_parent, caregiver, and viewer. A viewer sees the calendar, history, and predictions but cannot open a writable day sheet — tapping a day shows a read-only view naming the view-only reason, distinct from an archived profile's read-only view. An outstanding invitation can be cancelled from Manage Guardians before it is redeemed (the same role ladder as removing a guardian: the primary guardian may cancel any invitation; a co-parent may cancel one it created plus any caregiver/viewer invitation) — cancelling makes the link permanently unredeemable. When two guardians log symptoms for the same date while offline or simultaneously, the app keeps both: their tags are merged (a set union) onto the surviving entry rather than one caregiver's tags being silently destroyed; flow and the free-text note remain last-writer-wins, since neither has a merge that is both order-independent and meaningful. Plan: docs/plans/2026-09-06-001-feat-family-sharing-invitations-plan.md (gap-closure pass against the shipped guardian/sharing infrastructure from issue #8).

Config & credentials

Remote backend: Supabase Cloud (dleexnnevuuddcgcpztq); crash reporting: Sentry. Configuration reaches the app in exactly one way and the tooling in another:

  • App (client-side, build time): lib/config.dart (AppConfig) reads SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, SENTRY_DSN, SENTRY_TRACES_SAMPLE_RATE, GOOGLE_IOS_CLIENT_ID, and GOOGLE_WEB_CLIENT_ID via --dart-define. Empty means unconfigured — SENTRY_TRACES_SAMPLE_RATE stays empty in every workflow (opt-in only, after issue #19). For local runs copy dart_defines.example.json to dart_defines.json (gitignored; client-safe values only — the publishable key, DSN, and Google client ids are designed to ship inside the binary) and pass --dart-define-from-file=dart_defines.json. The iOS reversed Google client id is committed as a URL scheme in ios/Runner/Info.plist once the ids exist.
  • Tooling (server-side only): .env (gitignored; .env.example is the template) feeds the Supabase CLI and direct database access — project ref, database password, DATABASE_URL. The app never reads it.
  • CI: GitHub repository secrets on wjdavis5/lunarlog supply the five client-safe defines to every workflow build; the production environment holds the migration credentials (SUPABASE_ACCESS_TOKEN, SUPABASE_DB_PASSWORD, SUPABASE_PROJECT_REF) behind a required reviewer.

Never commit credential values. The full list of secrets, the dashboard settings the code cannot apply, and the go-live checklist live in AGENTS.md and docs/ops/supabase-go-live.md.

Lab context

Part of the home lab; the canonical inventory lives in the lab root's CLAUDE.md (C:\git on the lab desktop).

Verified

  • 2026-09-06, Flutter 3.47.2 stable on Windows (branch issue-50): flutter analyze clean (0 issues); flutter test 903/903 passed; dart run tool/quality_gate.dart passed (90% coverage floor + per-method CRAP gate). Not run in this environment: the pgTAP suite (needs Docker), the iOS build (needs Williams-Mini), and the manual device checklist.
  • 2026-09-06, Flutter 3.47.2 stable on Windows (branch issue-5, Issue #5 caregiver alerts and reminders): flutter analyze clean (0 issues); flutter test 955/955 passed; dart run tool/quality_gate.dart passed (93.25% coverage, 0 CRAP-gate methods over threshold); flutter build apk --debug succeeded with no --dart-defines (R17/R22); npx supabase@2.116.0 start + db reset --local + test db --local ran 373/373 pgTAP tests; deno test in supabase/functions/ passed 41/41. Not run in this environment: the iOS build (macOS only) and every real-delivery device-checklist item (no Firebase project or physical device) — tracked in docs/ops/supabase-go-live.md.
  • 2026-09-03, Flutter 3.47.2 stable on Windows (branch feat/supabase-auth-cloud-sync): flutter analyze clean (0 issues); flutter test 377/377 passed; flutter build web --release (no defines) and flutter build apk --debug both succeeded (the Android Gradle build prints an upstream warning that sentry_flutter applies the Kotlin Gradle Plugin itself; harmless today, but a future Flutter may reject it); npx supabase@2.116.0 start + db reset --local + test db --local ran 135/135 pgTAP tests. Dependencies: supabase_flutter 2.17.2, sign_in_with_apple 8.2.0, sentry_flutter 9.28.0, Supabase CLI 2.116.0. Not run in this environment: the iOS build on Williams-Mini, the device checklist, the Sentry smoke test, supabase db push --dry-run, and the MCP get_advisors check — all tracked in docs/ops/supabase-go-live.md.
  • 2026-09-02, Flutter 3.47.2 stable on Williams-Mini (macOS arm64, Xcode 26.6): flutter analyze clean (0 issues); flutter test 162/162 tests passed; flutter build ipa --release --no-codesign built Runner.xcarchive (179.9MB); flutter build ios --release --no-codesign built Runner.app (20.3MB). (Pre-dates the Sign in with Apple entitlement; re-verify.)
  • 2026-09-02, Flutter 3.47.2 stable on Windows: flutter analyze clean (0 issues); flutter test 162/162 unit & widget tests passed across domain, data, gate, notifications, profiles, logging, and overview suites; ios/Runner.xcodeproj configured with PRODUCT_BUNDLE_IDENTIFIER = com.wjdavis5.lunarlog, DEVELOPMENT_TEAM = 5273C9R3V4, ExportOptions.plist, and PrivacyInfo.xcprivacy.
  • 2026-08-30, Flutter 3.47.2 stable on Windows: flutter doctor -v all green (Android SDK 36.0.0, all licenses accepted, Chrome present); flutter analyze clean; flutter test 1/1 passed; flutter build apk --debug and flutter build web --release both succeeded.
  • Re-verify after a Flutter SDK upgrade or any change to build config.

Known limitations (accepted)

  • Backup is account-based: a device signed in to an account keeps a copy of its data in that account and can restore it on another device. A device that never signed in has no backup — losing it loses the data. "Export my data" (Your data section) saves a JSON file of profiles and entries through the share sheet; it is still a manual, one-time export, not an automatic backup mechanism, but as of issue #140 that file is no longer a dead end — "Import from file" (same section) reads it back, previews what would change, and merges it into the local store, so exporting before a wipe or reinstall is a real (manual) restore path even for a device that never signed in.
  • In-app account deletion and JSON export have shipped in code (issue #17), but release itself still gates on the mechanically-enforced check: no App Store submission and no Play production dispatch until the RELEASE_GATE_ACCOUNT_DELETION repository variable is set to shipped (.github/scripts/check-release-gate.sh fails closed until then; a Play production dispatch also requires typing production into confirm_production). The variable read shipped on 2026-10-05 (set 2026-09-12); gh variable list shows its current value. Flipping the variable — once issue #17 has merged and the device checklist has passed — is a separate, deliberate release action, not automatic from merging the code.
  • "Sign out everywhere" revokes sessions, not tokens: other devices keep access until their JWT expires (up to an hour at Supabase's default, the chosen setting), which is why the app's copy says other devices may take up to an hour to notice.
  • The former "no fertility features, by design; do not add them" commitment was removed (#142, following the owner decision in #123). Fertile-window and ovulation estimation (#143) has shipped: a calendar-method back-calculation (next predicted period start minus an assumed 14-day luteal-phase length, window ovulation −5…+1 days) computed on-device from already-logged cycle dates, carrying the exact same confidence tier as the period prediction it is derived from, shown on the calendar and in Insights with its own not-contraception disclaimer, and available on every profile including minors' with no restriction. Fertility-signal logging — BBT, cervical mucus, ovulation tests (#144), which would refine the estimate beyond calendar math — is still not built. PRIVACY.md records both the narrowed promise and the fertile-window disclosure in its change history.
  • App-switcher snapshots are suppressed by an opaque Flutter cover whenever the app is not resumed, plus FLAG_SECURE on Android (a tiny platform channel in MainActivity.kt); iOS has no Flutter-level FLAG_SECURE equivalent, so the cover is the only mechanism there.
  • Backgrounding does not re-lock while the app's own system UI is on screen — its credential prompt, the Google picker, the Apple sheet. Neither platform distinguishes those from the operator genuinely leaving (iOS reports a spurious hidden for native modals, flutter/flutter#146734; Android's passcode fallback really does background the activity), and treating them as departures made the app impossible to unlock at all (issue #65). Content stays covered for the whole window; a departure the window absorbed is answered fail-closed the moment the system UI comes down, and a window left open locks after two minutes regardless of the inactivity toggle. The reminder scheduler's notification-permission prompt is covered by this too, on both platforms (issue #168): it is raised by the overview hint's "Turn on reminders" action, which runs inside the same window. The scheduler never asks at startup — iOS since issue #863, Android since issue #1425 — so starting reminders at launch opens no window and covers nothing. A push-configured build does still ask at launch, from push registration; that ask opens the window around its own request, and only when the system dialog can actually appear.
  • iOS: the database file now lives under getApplicationSupportDirectory(), not Documents/ (issue #244 — Documents/ is included in iCloud/device backup by default, and so is Application Support: only tmp/ and Library/Caches/ are excluded automatically. The directory move is hygiene, not the control), with a one-time crash-safe startup migration that relocates a pre-#244 install's Documents/lunarlog.db (and its -wal/-shm/-journal siblings) the first time it runs (lib/startup/database_relocation.dart's relocateLegacyDatabase — stages every sibling, verifies the staged copy against the legacy database, then atomically renames each into place and writes a completion sentinel last; any failure rolls back the partial target and falls back to opening the legacy file). The actual backup control is AppDelegate.swift's lunarlog/privacy channel (the iOS half of the same channel Android's FLAG_SECURE handler in MainActivity.kt uses): it marks the file (and its containing directory, so a -journal created later is covered too) NSURLIsExcludedFromBackupKey and NSFileProtectionCompleteUntilFirstUserAuthentication (issue #906) — best effort, but a failure is reported (a type-only breadcrumb plus Sentry.captureException, never a path) rather than swallowed silently. This has not yet been verified on a real device/Mac build (tracked as a blocker on issue #244; flutter analyze/flutter test/flutter build apk all pass, but the iOS-only platform-channel code cannot run under flutter test and this repo's Windows dev box cannot build iOS). There is no app-managed database key to pin or unpin now that at-rest protection is the OS's own (docs/ops/ios-export-compliance.md). The Supabase session and PKCE verifier are stored with first_unlock_this_device and never travel in a backup.
  • Android: allowBackup="false" (device/cloud backup off entirely) is now joined by android:dataExtractionRules (API 31+) excluding the database file from both cloud backup and device-to-device transfer, and android:fullBackupContent for the pre-31 schema — see android/app/src/main/res/xml/data_extraction_rules.xml and backup_rules.xml (issue #244).
  • Realtime co-caregiver sync (issue #77) publishes only a dedicated public.sync_signals table (profile_id, updated_at — no health content) to supabase_realtime, never public.profiles/public.day_entries themselves: Realtime's WALRUS decodes with wal2json, which only honors a publication as a table-level membership list, so a narrow publication column list on the source tables would not have kept note/tags/flow off the websocket given this schema's table-wide select grant for authenticated (has_column_privilege, the check Realtime actually applies, passes regardless of the publication's declared columns). This cannot be exercised end-to-end in CI: local and CI Supabase both start with -x realtime (no Realtime container), so supabase/tests/realtime_publication_test.sql proves catalog state (what is/isn't published, sync_signals's column list) and trigger behavior (writes populate sync_signals; a non-guardian cannot read another family's signal row; the publication guard corrects a simulated Studio-toggle drift instead of skipping it) from pgTAP. A manual, empirical check against a real Realtime container is still required before every merge that touches supabase/migrations/20260905100000_realtime_publication.sql or the coordinator's subscription shape: run supabase/tests/manual/verify_realtime_delivery.mjs (see its header for usage) against local Supabase started without excluding realtime (Docker required), or against the cloud project. It was run for this PR against a real local Realtime container and confirmed both halves: (a) the delivered sync_signals payload contains only profile_id/updated_at, and (b) Realtime refuses day_entries/profiles subscriptions outright (an "Unable to subscribe to changes..." postgres_changes system error) rather than silently filtering them, so no entry content reaches the websocket via those tables at all. The script asserts on this directly now (PR #92 review round 2): every channel must genuinely settle a postgres_changes system message (not just create a channel object) or the script throws, and the profiles write it checks against happens after subscribing — the original version wrote it before, which made that half of the check structurally unable to fail. The script also creates exactly one throwaway auth user/profile/day-entries row and deletes all three (and the sync_signals row they generate) in a finally, so repeated runs against the cloud project do not accumulate test data or leave real-looking minor's-health-log content behind. This check is deliberately not wired into CI — investigated (a realtime-inclusive CI stack was prototyped) and found out of proportion for one migration's regression coverage; see the plan's KTD4 for why.
  • Caregiver alerts and reminders (issue #5) have no CI coverage of real push delivery: db-tests covers the SQL (eligibility, the outbox, quiet-hours math, the missed-entry scan) and edge-functions covers push-dispatch's claim-before-send logic and FCM message shape against fakes, but nothing in CI proves a real FCM/APNs delivery, a real quiet-hours release, or the cloud project's pg_cron schedule actually firing — those are manual device-checklist items (docs/ops/supabase-go-live.md). Push is entirely absent (AppConfig.hasPush) on a build without every FCM_* dart-define, and richer lock-screen copy behind an opt-in was deliberately not built — every caregiver alert shows the same fixed generic text the app's own local reminders already use, by construction, not by a copy choice that could later be widened.

License

This project is licensed under the PolyForm Noncommercial License 1.0.0.
You are free to view, modify, and run this software for noncommercial personal and family use. Commercial use, reproduction, distribution, or resale is strictly prohibited without a separate commercial license from the author.

About

No description, website, or topics provided.

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages