diff --git a/.rubocop.yml b/.rubocop.yml
index 68349d6..267d958 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -12,3 +12,6 @@ AllCops:
# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]`
# Layout/SpaceInsideArrayLiteralBrackets:
# Enabled: false
+
+Bundler/OrderedGems:
+ Enabled: true
diff --git a/.ruby-version b/.ruby-version
index ea897ac..1cf76f5 100644
--- a/.ruby-version
+++ b/.ruby-version
@@ -1 +1 @@
-ruby-4.0.5
+ruby-4.0.6
diff --git a/Gemfile b/Gemfile
index d59d620..a8bcce6 100644
--- a/Gemfile
+++ b/Gemfile
@@ -2,23 +2,25 @@ source "https://rubygems.org"
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
gem "rails", "~> 8.1.3"
-# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
-gem "propshaft"
-# Use sqlite3 as the database for Active Record
-gem "sqlite3", ">= 2.9.6"
-# Use the Puma web server [https://github.com/puma/puma]
-gem "puma", ">= 5.0"
+
+
+gem "devise", "~> 5.0"
# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails]
gem "importmap-rails"
-# Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev]
-gem "turbo-rails"
-# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev]
-gem "stimulus-rails"
# Build JSON APIs with ease [https://github.com/rails/jbuilder]
gem "jbuilder"
-
+# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
+gem "propshaft"
+# Use the Puma web server [https://github.com/puma/puma]
+gem "puma", ">= 5.0"
# Handle Cross-Origin Resource Sharing so the Expo app can call the API [https://github.com/cyu/rack-cors]
gem "rack-cors"
+# Use sqlite3 as the database for Active Record
+gem "sqlite3", ">= 2.9.6"
+# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev]
+gem "stimulus-rails"
+# Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev]
+gem "turbo-rails"
# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
# gem "bcrypt", "~> 3.1.7"
@@ -27,9 +29,9 @@ gem "rack-cors"
gem "tzinfo-data", platforms: %i[ windows jruby ]
# Use the database-backed adapters for Rails.cache, Active Job, and Action Cable
+gem "solid_cable"
gem "solid_cache"
gem "solid_queue"
-gem "solid_cable"
# Reduces boot times through caching; required in config/boot.rb
gem "bootsnap", require: false
@@ -51,7 +53,7 @@ gem "ruby-vips", "~> 2.0", require: false
group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"
-
+ gem "pry"
# Audits gems for known security defects (use config/bundler-audit.yml to ignore issues)
gem "bundler-audit", require: false
diff --git a/Gemfile.lock b/Gemfile.lock
index 7ffa82e..402d23f 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -79,7 +79,9 @@ GEM
public_suffix (>= 2.0.2, < 8.0)
ast (2.4.3)
base64 (0.3.0)
+ bcrypt (3.1.22)
bcrypt_pbkdf (1.1.2)
+ bcrypt_pbkdf (1.1.2-arm64-darwin)
bigdecimal (4.1.2)
bindex (0.8.1)
bootsnap (1.24.6)
@@ -99,6 +101,7 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
+ coderay (1.1.3)
concurrent-ruby (1.3.8)
connection_pool (3.0.2)
crass (1.0.7)
@@ -106,6 +109,12 @@ GEM
debug (1.11.1)
irb (~> 1.10)
reline (>= 0.3.8)
+ devise (5.0.4)
+ bcrypt (~> 3.0)
+ orm_adapter (~> 0.1)
+ railties (>= 7.0)
+ responders
+ warden (~> 1.2.3)
dotenv (3.2.0)
drb (2.2.3)
ed25519 (1.4.0)
@@ -167,6 +176,7 @@ GEM
net-smtp
marcel (1.2.1)
matrix (0.4.3)
+ method_source (1.1.0)
mini_mime (1.1.5)
minitest (6.0.6)
drb (~> 2.0)
@@ -201,6 +211,7 @@ GEM
racc (~> 1.4)
nokogiri (1.19.4-x86_64-linux-musl)
racc (~> 1.4)
+ orm_adapter (0.5.0)
ostruct (0.6.3)
parallel (2.1.0)
parser (3.3.11.1)
@@ -214,6 +225,10 @@ GEM
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
rack
+ pry (0.16.0)
+ coderay (~> 1.1)
+ method_source (~> 1.0)
+ reline (>= 0.6.0)
public_suffix (7.0.5)
puma (8.0.2)
nio4r (~> 2.0)
@@ -274,6 +289,9 @@ GEM
regexp_parser (2.12.0)
reline (0.6.3)
io-console (~> 0.5)
+ responders (3.2.1)
+ actionpack (>= 7.0)
+ railties (>= 7.0)
rexml (3.4.4)
rubocop (1.88.1)
json (~> 2.3)
@@ -364,6 +382,8 @@ GEM
unicode-emoji (4.2.0)
uri (1.1.1)
useragent (0.16.11)
+ warden (1.2.9)
+ rack (>= 2.0.9)
web-console (4.3.0)
actionview (>= 8.0.0)
bindex (>= 0.4.0)
@@ -383,6 +403,7 @@ PLATFORMS
aarch64-linux-musl
arm-linux-gnu
arm-linux-musl
+ arm64-darwin-22
arm64-darwin-23
arm64-darwin-25
x86_64-linux
@@ -395,11 +416,13 @@ DEPENDENCIES
bundler-audit
capybara
debug
+ devise (~> 5.0)
image_processing (~> 2.0)
importmap-rails
jbuilder
kamal
propshaft
+ pry
puma (>= 5.0)
rack-cors
rails (~> 8.1.3)
@@ -432,7 +455,9 @@ CHECKSUMS
addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
+ bcrypt (3.1.22) sha256=1f0072e88c2d705d94aff7f2c5cb02eb3f1ec4b8368671e19112527489f29032
bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6
+ bcrypt_pbkdf (1.1.2-arm64-darwin) sha256=afdd6feb6ed5a97b8e44caacb3f2d641b98af78e6a516d4a3520b69af5cf9fea
bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e
bootsnap (1.24.6) sha256=c60bab88c70332290f0a2636a288f675299eb4f804a02a3c085b42eca9da164a
@@ -440,11 +465,13 @@ CHECKSUMS
builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f
bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9
capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef
+ coderay (1.1.3) sha256=dc530018a4684512f8f38143cd2a096c9f02a1fc2459edcfe534787a7fc77d4b
concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1
connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295
date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0
debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6
+ devise (5.0.4) sha256=d605f2b85854e74e56ee789e2d398702bc2d06e6bcd894717a670a3199c74cc1
dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
ed25519 (1.4.0) sha256=16e97f5198689a154247169f3453ef4cfd3f7a47481fde0ae33206cdfdcac506
@@ -475,6 +502,7 @@ CHECKSUMS
mail (2.9.1) sha256=06574eca475253d6c18145dd70af80d0eb970182d55053497c5f4d797ea160e8
marcel (1.2.1) sha256=1678e9360e32f9eafa917c80029e2f6d10b2715c66a4b87b6d0da9b9cd1f859f
matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b
+ method_source (1.1.0) sha256=181301c9c45b731b4769bc81e8860e72f9161ad7d66dd99103c9ab84f560f5c5
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1
msgpack (1.8.3) sha256=8bda4a6428d3244e50d6bd55854d354edbada88a4e1f4f5731a39a0f86bee6a1
@@ -493,6 +521,7 @@ CHECKSUMS
nokogiri (1.19.4-arm64-darwin) sha256=a46db9853286e6597b36ebc6953817d15acf3a299583eb3f89fdc6f91dd63527
nokogiri (1.19.4-x86_64-linux-gnu) sha256=379fae440b28915e3f19d752ce2dcf8465ed2b2fbefd2a7ca0dd497bc981a06a
nokogiri (1.19.4-x86_64-linux-musl) sha256=17dfb7c1fa194ae02fbf7c51a7afc8d278045ab3fdacfd86f91d02d7b274470b
+ orm_adapter (0.5.0) sha256=aa5d0be5d540cbb46d3a93e88061f4ece6a25f6e97d6a47122beb84fe595e9b9
ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356
parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54
@@ -500,6 +529,7 @@ CHECKSUMS
prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
propshaft (1.3.2) sha256=1d56a3e56a92c21bfc29caf07406b5386b00d4c47ddf357cf989a5a234b1389e
+ pry (0.16.0) sha256=d76c69065698ed1f85e717bd33d7942c38a50868f6b0673c636192b3d1b6054e
public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb
raabro (1.5.0) sha256=3f998a7bc84f9c84df3ab580634d2e0a5bda4f0841168d56035f529c9877440a
@@ -519,6 +549,7 @@ CHECKSUMS
rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469
regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb
reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835
+ responders (3.2.1) sha256=c2772970cbda654c0e772d852e5a851e82cd8e96f5fa958650861ba75b8ffa84
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
rubocop (1.88.1) sha256=726af773d6bc169ed3ff852f3ca020b7c58d39c34e7a8d879a8e0147cc994f26
rubocop-ast (1.50.0) sha256=b9ca88300da0803ee222ad20cdb30494c0a784eed06fdc35d254b06d662788db
@@ -555,6 +586,7 @@ CHECKSUMS
unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6
useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844
+ warden (1.2.9) sha256=46684f885d35a69dbb883deabf85a222c8e427a957804719e143005df7a1efd0
web-console (4.3.0) sha256=e13b71301cdfc2093f155b5aa3a622db80b4672d1f2f713119cc7ec7ac6a6da4
websocket (1.2.11) sha256=b7e7a74e2410b5e85c25858b26b3322f29161e300935f70a0e0d3c35e0462737
websocket-driver (0.8.2) sha256=97c556b019bf3410b4961002ac501621e9322d3f8a7bc02161a09301cc4c4146
diff --git a/app/assets/images/horse.jpg b/app/assets/images/horse.jpg
new file mode 100644
index 0000000..201e1bd
Binary files /dev/null and b/app/assets/images/horse.jpg differ
diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css
index fe93333..a42eb9d 100644
--- a/app/assets/stylesheets/application.css
+++ b/app/assets/stylesheets/application.css
@@ -1,10 +1,624 @@
/*
- * This is a manifest file that'll be compiled into application.css.
+ * The web app's stylesheet. One file on purpose: Propshaft serves
+ * app/assets/stylesheets without a build step, and a single file means no
+ * questions about which sheet loads first.
*
- * With Propshaft, assets are served efficiently without preprocessing steps. You can still include
- * application-wide styles in this file, but keep in mind that CSS precedence will follow the standard
- * cascading order, meaning styles declared later in the document or manifest will override earlier ones,
- * depending on specificity.
+ * THE PALETTE IS NOT ORIGINAL TO THIS FILE. It is copied from
+ * mobile/src/global.css, which is the source of truth and carries the
+ * reasoning - including why the light-mode primary is a deeper teal than the
+ * brand teal (white text on the brand teal is 2.8:1, and WCAG AA wants 4.5:1).
+ * Values are `H S% L%` triplets rather than colours, same as over there, so the
+ * two files diff cleanly. Change one, change both.
*
- * Consider organizing styles into separate files for maintainability.
+ * The mobile app switches schemes with a `.dark` class because it has a
+ * toggle. This side has no toggle, so it follows the operating system via
+ * prefers-color-scheme instead. That is the only intentional difference.
+ *
+ * Everything below styles plain HTML elements. Nothing here needs a class on a
+ * view, which is what lets the Rails scaffolds pick it up without being edited.
+ */
+
+:root {
+ /* Light. Mirrors :root in mobile/src/global.css. */
+ --background: 180 30% 99%; /* #FCFDFD */
+ --foreground: 196 28% 16%; /* #1D2E34 */
+ --card: 0 0% 100%; /* #FFFFFF */
+ --card-foreground: 196 28% 16%; /* #1D2E34 */
+ --primary: 181 66% 29%; /* #19797B */
+ --primary-foreground: 0 0% 100%; /* #FFFFFF */
+ --secondary: 180 30% 95%; /* #EEF6F6 */
+ --secondary-foreground: 194 30% 22%; /* #274149 */
+ --muted: 180 28% 96%; /* #F2F8F8 */
+ --muted-foreground: 196 14% 40%; /* #586D74 */
+ --accent: 181 40% 92%; /* #E2F2F3 */
+ --accent-foreground: 193 45% 22%; /* #1F4651 */
+ --destructive: 352 58% 45%; /* #B53042 */
+ --destructive-foreground: 0 0% 100%; /* #FFFFFF */
+ --success: 168 45% 38%; /* #358D7B - chart-3 over in global.css */
+ --border: 185 22% 88%; /* #DAE6E7 */
+ --input: 185 22% 85%; /* #D0E0E1 */
+ --ring: 181 66% 29%; /* #19797B */
+
+ --radius: 0.875rem;
+
+ /* Spacing, mirroring Spacing in mobile/src/constants/theme.ts. */
+ --space-1: 4px;
+ --space-2: 8px;
+ --space-3: 16px;
+ --space-4: 24px;
+ --space-5: 32px;
+ --space-6: 64px;
+
+ /* MaxContentWidth in mobile/src/constants/theme.ts. */
+ --content-width: 800px;
+
+ /* The same stacks as :root in mobile/src/global.css. Spline Sans and Inter
+ are not loaded - no webfont request, no new dependency - so in practice
+ this resolves to the system UI font, which is what the mobile web build
+ falls back to as well. */
+ --font-display: Spline Sans, Inter, ui-sans-serif, system-ui, sans-serif,
+ Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
+ Liberation Mono, Courier New, monospace;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ /* Mirrors .dark:root in mobile/src/global.css. */
+ --background: 196 30% 8%; /* #0E171B */
+ --foreground: 180 18% 94%; /* #EDF2F2 */
+ --card: 196 26% 11%; /* #151F23 */
+ --card-foreground: 180 18% 94%; /* #EDF2F2 */
+ --primary: 181 66% 41%; /* #24ABAE - the brand teal */
+ --primary-foreground: 192 50% 8%; /* #0A1B1F */
+ --secondary: 196 22% 17%; /* #223035 */
+ --secondary-foreground: 180 16% 92%; /* #E7EEEE */
+ --muted: 196 22% 16%; /* #202D32 */
+ --muted-foreground: 188 14% 68%; /* #A2B6B9 */
+ --accent: 194 26% 22%; /* #2A4047 */
+ --accent-foreground: 180 16% 92%; /* #E7EEEE */
+ --destructive: 352 55% 48%; /* #BE3749 */
+ --destructive-foreground: 0 0% 100%; /* #FFFFFF */
+ --success: 168 45% 52%; /* #4EBCA6 */
+ --border: 196 20% 20%; /* #29383D */
+ --input: 196 20% 24%; /* #314349 */
+ --ring: 181 66% 41%; /* #24ABAE */
+ }
+}
+
+/* ---------------------------------------------------------------- base --- */
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+html {
+ -webkit-text-size-adjust: 100%;
+}
+
+body {
+ margin: 0;
+ background: hsl(var(--background));
+ color: hsl(var(--foreground));
+ font-family: var(--font-display);
+ /* 17px. The people using this are often tired, often on a phone in bad
+ light, often older. Default 16px is the floor, not the target. */
+ font-size: 1.0625rem;
+ line-height: 1.6;
+ -webkit-font-smoothing: antialiased;
+}
+
+h1, h2, h3 {
+ line-height: 1.25;
+ text-wrap: balance;
+ margin: 0 0 var(--space-3);
+}
+
+h1 { font-size: 1.875rem; letter-spacing: -0.02em; }
+h2 { font-size: 1.375rem; }
+h3 { font-size: 1.125rem; }
+
+p { margin: 0 0 var(--space-3); }
+
+a {
+ color: hsl(var(--primary));
+ text-decoration-color: hsl(var(--primary) / 0.4);
+ text-underline-offset: 3px;
+}
+
+a:hover { text-decoration-color: hsl(var(--primary)); }
+
+/* One focus ring for everything. Visible in both schemes at 3:1 or better. */
+:where(a, button, input, select, textarea, summary):focus-visible {
+ outline: 2px solid hsl(var(--ring));
+ outline-offset: 2px;
+ border-radius: calc(var(--radius) - 6px);
+}
+
+hr {
+ border: 0;
+ border-top: 1px solid hsl(var(--border));
+ margin: var(--space-4) 0;
+}
+
+img { max-width: 100%; height: auto; }
+
+code, pre { font-family: var(--font-mono); font-size: 0.9375em; }
+
+/* -------------------------------------------------------------- layout --- */
+
+.skip-link {
+ position: absolute;
+ left: var(--space-2);
+ top: -3rem;
+ z-index: 10;
+ padding: var(--space-2) var(--space-3);
+ border-radius: calc(var(--radius) - 4px);
+ background: hsl(var(--primary));
+ color: hsl(var(--primary-foreground));
+ text-decoration: none;
+ transition: top 120ms ease;
+}
+
+.skip-link:focus { top: var(--space-2); }
+
+.site-header {
+ border-bottom: 1px solid hsl(var(--border));
+ background: hsl(var(--card));
+}
+
+.site-header__inner,
+.site-main,
+.site-footer__inner {
+ max-width: var(--content-width);
+ margin-inline: auto;
+ padding-inline: var(--space-3);
+}
+
+.site-header__inner {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: var(--space-2) var(--space-4);
+ padding-block: var(--space-3);
+}
+
+.site-title {
+ font-size: 1.0625rem;
+ font-weight: 650;
+ letter-spacing: -0.01em;
+ color: hsl(var(--foreground));
+ text-decoration: none;
+}
+
+.site-nav {
+ /* Filling the row is what lets the account group's auto margin push it to
+ the far edge, away from the section links. */
+ flex: 1;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: var(--space-1) var(--space-3);
+ font-size: 0.9375rem;
+}
+
+.site-nav__account {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: var(--space-1) var(--space-3);
+ margin-inline-start: auto;
+}
+
+/* Whose account this is - context, not a place you can go. */
+.site-nav__user {
+ color: hsl(var(--muted-foreground));
+ overflow-wrap: anywhere;
+}
+
+.site-nav a {
+ color: hsl(var(--muted-foreground));
+ text-decoration: none;
+ padding-block: var(--space-1);
+}
+
+.site-nav a:hover { color: hsl(var(--foreground)); }
+
+.site-nav a[aria-current="page"] {
+ color: hsl(var(--primary));
+ font-weight: 600;
+}
+
+.site-main { padding-block: var(--space-5) var(--space-6); }
+
+/* ---------------------------------------------------------------- admin --- */
+/*
+ * The administrative area - the lists of medications, types and forms that
+ * are set up once rather than used daily. It is not a separate app: same
+ * header, same palette. The one signal that you have stepped sideways into
+ * setup is this second nav band, on the muted grey the sheet already uses for
+ * table headings and field captions.
+ *
+ * These sections still live at /medications and friends, so the band is what
+ * ties them together. ApplicationHelper#admin_area? decides when it shows.
+ */
+
+.admin-nav {
+ border-bottom: 1px solid hsl(var(--border));
+ background: hsl(var(--muted));
+ font-size: 0.9375rem;
+}
+
+.admin-nav__inner {
+ max-width: var(--content-width);
+ margin-inline: auto;
+ padding-inline: var(--space-3);
+ padding-block: var(--space-2);
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: var(--space-1) var(--space-3);
+}
+
+/* Names the band rather than labelling any one link, so the row still reads
+ as "Admin: Overview, Medications, ..." when the header is scrolled away.
+ The nav's aria-label says the same thing for a screen reader. */
+.admin-nav__label {
+ color: hsl(var(--muted-foreground));
+ font-size: 0.8125rem;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
+}
+
+.admin-nav a {
+ color: hsl(var(--muted-foreground));
+ text-decoration: none;
+ padding-block: var(--space-1);
+}
+
+.admin-nav a:hover { color: hsl(var(--foreground)); }
+
+.admin-nav a[aria-current="page"] {
+ color: hsl(var(--primary));
+ font-weight: 600;
+}
+
+/* The opening sentence on /admin. Wider than body text would allow it to be
+ emphatic, so it stays the same size and only lightens. */
+.lede {
+ max-width: 46ch;
+ color: hsl(var(--muted-foreground));
+ margin-bottom: var(--space-4);
+}
+
+.admin-cards {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ gap: var(--space-3);
+ /* One column on a phone, two once there is room for two readable ones. */
+ grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr));
+}
+
+.admin-card {
+ position: relative;
+ padding: var(--space-3) var(--space-4);
+ border: 1px solid hsl(var(--border));
+ border-radius: var(--radius);
+ background: hsl(var(--card));
+}
+
+.admin-card:hover { border-color: hsl(var(--primary) / 0.5); }
+
+.admin-card__title {
+ font-size: 1.0625rem;
+ margin-bottom: var(--space-1);
+}
+
+/* Stretching the one link over the whole card makes the card a click target
+ without adding a second link to the same place - which a screen reader would
+ read out twice and a keyboard user would have to tab through twice. */
+.admin-card__link { text-decoration: none; }
+
+.admin-card__link::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+}
+
+/* With the pseudo-element covering the card, the focus ring would otherwise
+ draw around the words alone while the whole card is what activates. */
+.admin-card__link:focus-visible { outline: none; }
+
+.admin-card:has(.admin-card__link:focus-visible) {
+ outline: 2px solid hsl(var(--ring));
+ outline-offset: 2px;
+}
+
+.admin-card__description {
+ margin: 0;
+ color: hsl(var(--muted-foreground));
+ font-size: 0.9375rem;
+}
+
+.site-footer {
+ border-top: 1px solid hsl(var(--border));
+ color: hsl(var(--muted-foreground));
+ font-size: 0.875rem;
+}
+
+.site-footer__inner { padding-block: var(--space-4); }
+.site-footer p { margin: 0; }
+
+/* ------------------------------------------------------------- controls --- */
+
+label {
+ display: block;
+ margin-bottom: var(--space-1);
+ font-size: 0.9375rem;
+ font-weight: 550;
+}
+
+input:not([type="submit"]):not([type="checkbox"]):not([type="radio"]),
+select,
+textarea {
+ width: 100%;
+ max-width: 26rem;
+ padding: 10px var(--space-2);
+ border: 1px solid hsl(var(--input));
+ border-radius: calc(var(--radius) - 4px);
+ background: hsl(var(--card));
+ color: hsl(var(--foreground));
+ font: inherit;
+ font-size: 1rem; /* Below 16px, iOS Safari zooms on focus. */
+}
+
+input::placeholder,
+textarea::placeholder { color: hsl(var(--muted-foreground)); }
+
+/* A hand-written <%= f.check_box %><%= f.label %> pair puts the two next to
+ each other, where the block label above would drop the caption onto its own
+ line under a bare box. Beside a checkbox or radio, a label stays inline. */
+input[type="checkbox"] + label,
+input[type="radio"] + label {
+ display: inline;
+ margin-bottom: 0;
+ font-weight: 400;
+}
+
+/* The scaffold forms wrap each field in a bare
. This is what gives them
+ breathing room without touching _form.html.erb. */
+form > div { margin-bottom: var(--space-3); }
+
+input[type="submit"],
+button {
+ padding: 10px var(--space-4);
+ border: 1px solid transparent;
+ border-radius: calc(var(--radius) - 4px);
+ background: hsl(var(--primary));
+ color: hsl(var(--primary-foreground));
+ font: inherit;
+ font-size: 1rem;
+ font-weight: 550;
+ cursor: pointer;
+}
+
+input[type="submit"]:hover,
+button:hover { background: hsl(var(--primary) / 0.9); }
+
+/* button_to ... method: :delete posts a form carrying a hidden _method=delete,
+ which is the only thing distinguishing "Destroy this person" from any other
+ button. Without this it renders in the brand teal, making delete the loudest
+ control on the show page. :has is safe here - ApplicationController already
+ gates on allow_browser versions: :modern. */
+form:has(input[name="_method"][value="delete"]) button {
+ background: hsl(var(--destructive));
+ color: hsl(var(--destructive-foreground));
+}
+
+form:has(input[name="_method"][value="delete"]) button:hover {
+ background: hsl(var(--destructive) / 0.9);
+}
+
+/* --------------------------------------------------------------- tables --- */
+/*
+ * The prescriptions index is a log, so it is a real table. Seven columns will
+ * not fit a phone, and neither hiding columns nor collapsing each row into a
+ * card keeps a log scannable - so the table keeps its shape and the wrapper
+ * around it scrolls sideways instead.
+ */
+
+.table-scroll {
+ overflow-x: auto;
+ margin-bottom: var(--space-4);
+ border: 1px solid hsl(var(--border));
+ border-radius: var(--radius);
+ background: hsl(var(--card));
+ /* The rounded corners would otherwise be sliced off by the header band. */
+ overflow-y: hidden;
+}
+
+/* --content-width is a comfortable measure for reading sentences, and a log is
+ not sentences: seven columns do not fit in it, and scrolling sideways on a
+ wide monitor to reach the last two is silly. So once the window is wide
+ enough to spare it, the table steps out of the reading column and centres
+ itself on the page. Percentage margins resolve against the containing block,
+ so `50% - half the width` is what centres a box wider than its parent. */
+@media (min-width: 64rem) {
+ .table-scroll {
+ --table-width: min(1120px, 100vw - var(--space-6));
+
+ width: var(--table-width);
+ margin-inline: calc(50% - var(--table-width) / 2);
+ }
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.9375rem;
+}
+
+th,
+td {
+ padding: var(--space-2) var(--space-3);
+ text-align: left;
+ vertical-align: top;
+}
+
+/* Same treatment as the field captions inside the scaffold cards below, so a
+ column heading and a field label read as the same kind of thing. */
+thead th {
+ border-bottom: 1px solid hsl(var(--border));
+ background: hsl(var(--muted));
+ color: hsl(var(--muted-foreground));
+ font-size: 0.8125rem;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
+ white-space: nowrap;
+}
+
+tbody tr + tr th,
+tbody tr + tr td { border-top: 1px solid hsl(var(--border)); }
+
+tbody tr:hover { background: hsl(var(--accent) / 0.5); }
+
+/* The row header - which medication this row is about. Everything else in the
+ row is a detail of it, so only this column is allowed to wrap. */
+tbody th[scope="row"] {
+ font-weight: 550;
+ min-width: 11rem;
+}
+
+tbody td { white-space: nowrap; }
+
+/* A second line under the row header: the form the medication comes in. */
+.table-note {
+ display: block;
+ color: hsl(var(--muted-foreground));
+ font-size: 0.8125rem;
+ font-weight: 400;
+}
+
+/* Whether a prescription is current. The word says it; the colour only agrees
+ with the word, so this still reads with colour vision differences or in
+ print. */
+.status {
+ display: inline-block;
+ padding: 1px var(--space-2);
+ border: 1px solid;
+ border-radius: 999px;
+ font-size: 0.8125rem;
+ font-weight: 600;
+}
+
+.status--active {
+ border-color: hsl(var(--success) / 0.4);
+ background: hsl(var(--success) / 0.14);
+ color: hsl(var(--foreground));
+}
+
+.status--stopped {
+ border-color: hsl(var(--border));
+ background: hsl(var(--muted));
+ color: hsl(var(--muted-foreground));
+}
+
+/* ------------------------------------------------- scaffold accommodation --- */
+/*
+ * Everything below exists so the generator's output looks deliberate without
+ * anyone editing app/views. If a view is ever rewritten by hand, the matching
+ * rule here can go with it.
*/
+
+/* The partials render
via dom_id. Carding them turns the
+ generator's field dumps into something readable, on both index and show.
+ The div is load-bearing: form_with names its fields person_first_name,
+ medication_name and so on, so an unanchored [id^="person_"] would card every
+ input on every form as well. */
+div[id^="person_"],
+div[id^="medication_"],
+div[id^="prescription_"] {
+ padding: var(--space-3) var(--space-4);
+ margin-bottom: var(--space-3);
+ border: 1px solid hsl(var(--border));
+ border-radius: var(--radius);
+ background: hsl(var(--card));
+}
+
+/* Inside those cards the pattern is Field name: value.
+ Demoting the label and leading the value reads as a definition list. */
+div[id^="person_"] strong,
+div[id^="medication_"] strong,
+div[id^="prescription_"] strong {
+ display: block;
+ color: hsl(var(--muted-foreground));
+ font-size: 0.8125rem;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
+}
+
+div[id^="person_"] > div,
+div[id^="medication_"] > div,
+div[id^="prescription_"] > div { margin-bottom: var(--space-2); }
+
+div[id^="person_"] > div:last-child,
+div[id^="medication_"] > div:last-child,
+div[id^="prescription_"] > div:last-child { margin-bottom: 0; }
+
+/* The scaffolds emit `
` for the flash and
+ `
` for the error summary. The inline colour wins on
+ specificity, so these rules have to override it. Ugly, and deliberately so:
+ it is cheaper than editing nine views, and it disappears the moment those
+ views get written properly. */
+p[style*="green"] {
+ padding: var(--space-2) var(--space-3);
+ border-radius: calc(var(--radius) - 4px);
+ border: 1px solid hsl(var(--success) / 0.35);
+ background: hsl(var(--success) / 0.12);
+ color: hsl(var(--foreground)) !important;
+}
+
+/* With no flash to show, that
still renders - empty. Hide it. */
+p[style*="green"]:empty { display: none; }
+
+div[style*="red"] {
+ padding: var(--space-3) var(--space-4);
+ margin-bottom: var(--space-4);
+ border-radius: var(--radius);
+ border: 1px solid hsl(var(--destructive) / 0.35);
+ background: hsl(var(--destructive) / 0.1);
+ color: hsl(var(--foreground)) !important;
+}
+
+div[style*="red"] h2 {
+ font-size: 1rem;
+ color: hsl(var(--destructive));
+}
+
+div[style*="red"] ul { margin: 0; padding-left: 1.25rem; }
+
+/* Devise ships its own views, and sessions/new wraps the "remember me"
+ checkbox and its label in two consecutive
elements - so the caption
+ lands a line below a box with nothing beside it, whatever the label rule
+ above says. Rowing that one field puts them back together. Same bargain as
+ the scaffolds: cheaper than running the generator and owning the views. */
+.field:has(> p > input[type="checkbox"]) {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+.field:has(> p > input[type="checkbox"]) p { margin: 0; }
+
+.field:has(> p > input[type="checkbox"]) label {
+ margin: 0;
+ font-weight: 400;
+}
diff --git a/app/controllers/addresses_controller.rb b/app/controllers/addresses_controller.rb
new file mode 100644
index 0000000..ddfba73
--- /dev/null
+++ b/app/controllers/addresses_controller.rb
@@ -0,0 +1,56 @@
+class AddressesController < ApplicationController
+ before_action :set_person
+ before_action :set_address, only: %i[ show update destroy ]
+
+ def show
+ end
+
+ def create
+ @address = @person.build_address(address_params)
+
+ respond_to do |format|
+ if @address.save
+ format.html { redirect_to person_address_url(@person), notice: "Address was successfully created." }
+ format.json { render :show, status: :created, location: person_address_url(@person) }
+ else
+ format.html { render :new, status: :unprocessable_content }
+ format.json { render json: @address.errors, status: :unprocessable_content }
+ end
+ end
+ end
+
+ def update
+ respond_to do |format|
+ if @address.update(address_params)
+ format.html { redirect_to person_address_url(@person), notice: "Address was successfully updated.", status: :see_other }
+ format.json { render :show, status: :ok, location: person_address_url(@person) }
+ else
+ format.html { render :edit, status: :unprocessable_content }
+ format.json { render json: @address.errors, status: :unprocessable_content }
+ end
+ end
+ end
+
+ def destroy
+ @address.destroy!
+
+ respond_to do |format|
+ format.html { redirect_to person_url(@person), notice: "Address was successfully destroyed.", status: :see_other }
+ format.json { head :no_content }
+ end
+ end
+
+ private
+
+ def set_person
+ @person = Person.find(params[:person_id])
+ end
+
+ def set_address
+ @address = @person.address
+ end
+
+ def address_params
+ params.expect(address: [ :city, :state ])
+ end
+end
diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb
new file mode 100644
index 0000000..8e0354b
--- /dev/null
+++ b/app/controllers/admin_controller.rb
@@ -0,0 +1,12 @@
+# The landing page for the administrative area. It has no model of its own: the
+# sections it lists live in ApplicationHelper#admin_sections, and each is still
+# served by its own top-level controller.
+#
+# When these pages stop being open to everyone, this is the seam - the
+# before_action that gates the area goes here and in the three reference-data
+# controllers, or they all move under an Admin:: namespace inheriting from a
+# base controller that carries it.
+class AdminController < ApplicationController
+ def index
+ end
+end
diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb
new file mode 100644
index 0000000..6396a5f
--- /dev/null
+++ b/app/controllers/home_controller.rb
@@ -0,0 +1,7 @@
+class HomeController < ApplicationController
+ def index
+ end
+
+ def world
+ end
+end
diff --git a/app/controllers/medication_forms_controller.rb b/app/controllers/medication_forms_controller.rb
new file mode 100644
index 0000000..d5f3098
--- /dev/null
+++ b/app/controllers/medication_forms_controller.rb
@@ -0,0 +1,70 @@
+class MedicationFormsController < ApplicationController
+ before_action :set_medication_form, only: %i[ show edit update destroy ]
+
+ # GET /medication_forms or /medication_forms.json
+ def index
+ @medication_forms = MedicationForm.all
+ end
+
+ # GET /medication_forms/1 or /medication_forms/1.json
+ def show
+ end
+
+ # GET /medication_forms/new
+ def new
+ @medication_form = MedicationForm.new
+ end
+
+ # GET /medication_forms/1/edit
+ def edit
+ end
+
+ # POST /medication_forms or /medication_forms.json
+ def create
+ @medication_form = MedicationForm.new(medication_form_params)
+
+ respond_to do |format|
+ if @medication_form.save
+ format.html { redirect_to @medication_form, notice: "Medication form was successfully created." }
+ format.json { render :show, status: :created, location: @medication_form }
+ else
+ format.html { render :new, status: :unprocessable_content }
+ format.json { render json: @medication_form.errors, status: :unprocessable_content }
+ end
+ end
+ end
+
+ # PATCH/PUT /medication_forms/1 or /medication_forms/1.json
+ def update
+ respond_to do |format|
+ if @medication_form.update(medication_form_params)
+ format.html { redirect_to @medication_form, notice: "Medication form was successfully updated.", status: :see_other }
+ format.json { render :show, status: :ok, location: @medication_form }
+ else
+ format.html { render :edit, status: :unprocessable_content }
+ format.json { render json: @medication_form.errors, status: :unprocessable_content }
+ end
+ end
+ end
+
+ # DELETE /medication_forms/1 or /medication_forms/1.json
+ def destroy
+ @medication_form.destroy!
+
+ respond_to do |format|
+ format.html { redirect_to medication_forms_path, notice: "Medication form was successfully destroyed.", status: :see_other }
+ format.json { head :no_content }
+ end
+ end
+
+ private
+ # Use callbacks to share common setup or constraints between actions.
+ def set_medication_form
+ @medication_form = MedicationForm.find(params.expect(:id))
+ end
+
+ # Only allow a list of trusted parameters through.
+ def medication_form_params
+ params.expect(medication_form: [ :name ])
+ end
+end
diff --git a/app/controllers/medication_types_controller.rb b/app/controllers/medication_types_controller.rb
new file mode 100644
index 0000000..7bae334
--- /dev/null
+++ b/app/controllers/medication_types_controller.rb
@@ -0,0 +1,70 @@
+class MedicationTypesController < ApplicationController
+ before_action :set_medication_type, only: %i[ show edit update destroy ]
+
+ # GET /medication_types or /medication_types.json
+ def index
+ @medication_types = MedicationType.all
+ end
+
+ # GET /medication_types/1 or /medication_types/1.json
+ def show
+ end
+
+ # GET /medication_types/new
+ def new
+ @medication_type = MedicationType.new
+ end
+
+ # GET /medication_types/1/edit
+ def edit
+ end
+
+ # POST /medication_types or /medication_types.json
+ def create
+ @medication_type = MedicationType.new(medication_type_params)
+
+ respond_to do |format|
+ if @medication_type.save
+ format.html { redirect_to @medication_type, notice: "Medication type was successfully created." }
+ format.json { render :show, status: :created, location: @medication_type }
+ else
+ format.html { render :new, status: :unprocessable_content }
+ format.json { render json: @medication_type.errors, status: :unprocessable_content }
+ end
+ end
+ end
+
+ # PATCH/PUT /medication_types/1 or /medication_types/1.json
+ def update
+ respond_to do |format|
+ if @medication_type.update(medication_type_params)
+ format.html { redirect_to @medication_type, notice: "Medication type was successfully updated.", status: :see_other }
+ format.json { render :show, status: :ok, location: @medication_type }
+ else
+ format.html { render :edit, status: :unprocessable_content }
+ format.json { render json: @medication_type.errors, status: :unprocessable_content }
+ end
+ end
+ end
+
+ # DELETE /medication_types/1 or /medication_types/1.json
+ def destroy
+ @medication_type.destroy!
+
+ respond_to do |format|
+ format.html { redirect_to medication_types_path, notice: "Medication type was successfully destroyed.", status: :see_other }
+ format.json { head :no_content }
+ end
+ end
+
+ private
+ # Use callbacks to share common setup or constraints between actions.
+ def set_medication_type
+ @medication_type = MedicationType.find(params.expect(:id))
+ end
+
+ # Only allow a list of trusted parameters through.
+ def medication_type_params
+ params.expect(medication_type: [ :name ])
+ end
+end
diff --git a/app/controllers/medications_controller.rb b/app/controllers/medications_controller.rb
new file mode 100644
index 0000000..4c7c4d7
--- /dev/null
+++ b/app/controllers/medications_controller.rb
@@ -0,0 +1,75 @@
+class MedicationsController < ApplicationController
+ before_action :set_medication, only: %i[ show edit update destroy ]
+
+ # GET /medications or /medications.json
+ def index
+ @medications = Medication.all
+ end
+
+ # GET /medications/1 or /medications/1.json
+ def show
+ end
+
+ # GET /medications/new
+ def new
+ @medication = Medication.new
+ end
+
+ # GET /medications/1/edit
+ def edit
+ end
+
+ # POST /medications or /medications.json
+ def create
+ @medication = Medication.new(medication_params)
+
+ respond_to do |format|
+ if @medication.save
+ format.html { redirect_to @medication, notice: "Medication was successfully created." }
+ format.json { render :show, status: :created, location: @medication }
+ else
+ format.html { render :new, status: :unprocessable_content }
+ format.json { render json: @medication.errors, status: :unprocessable_content }
+ end
+ end
+ end
+
+ # PATCH/PUT /medications/1 or /medications/1.json
+ def update
+ respond_to do |format|
+ if @medication.update(medication_params)
+ format.html { redirect_to @medication, notice: "Medication was successfully updated.", status: :see_other }
+ format.json { render :show, status: :ok, location: @medication }
+ else
+ format.html { render :edit, status: :unprocessable_content }
+ format.json { render json: @medication.errors, status: :unprocessable_content }
+ end
+ end
+ end
+
+ # DELETE /medications/1 or /medications/1.json
+ def destroy
+ # A medication in use by a prescription refuses to be destroyed, so this
+ # reports the refusal rather than raising.
+ respond_to do |format|
+ if @medication.destroy
+ format.html { redirect_to medications_path, notice: "Medication was successfully destroyed.", status: :see_other }
+ format.json { head :no_content }
+ else
+ format.html { redirect_to @medication, alert: @medication.errors.full_messages.to_sentence, status: :see_other }
+ format.json { render json: @medication.errors, status: :unprocessable_content }
+ end
+ end
+ end
+
+ private
+ # Use callbacks to share common setup or constraints between actions.
+ def set_medication
+ @medication = Medication.find(params.expect(:id))
+ end
+
+ # Only allow a list of trusted parameters through.
+ def medication_params
+ params.expect(medication: [ :name, :medication_type_id, :side_effects ])
+ end
+end
diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb
new file mode 100644
index 0000000..f68035c
--- /dev/null
+++ b/app/controllers/people_controller.rb
@@ -0,0 +1,70 @@
+class PeopleController < ApplicationController
+ before_action :set_person, only: %i[ show edit update destroy ]
+
+ # GET /people or /people.json
+ def index
+ @people = Person.all
+ end
+
+ # GET /people/1 or /people/1.json
+ def show
+ end
+
+ # GET /people/new
+ def new
+ @person = Person.new
+ end
+
+ # GET /people/1/edit
+ def edit
+ end
+
+ # POST /people or /people.json
+ def create
+ @person = Person.new(person_params)
+
+ respond_to do |format|
+ if @person.save
+ format.html { redirect_to @person, notice: "Person was successfully created." }
+ format.json { render :show, status: :created, location: @person }
+ else
+ format.html { render :new, status: :unprocessable_content }
+ format.json { render json: @person.errors, status: :unprocessable_content }
+ end
+ end
+ end
+
+ # PATCH/PUT /people/1 or /people/1.json
+ def update
+ respond_to do |format|
+ if @person.update(person_params)
+ format.html { redirect_to @person, notice: "Person was successfully updated.", status: :see_other }
+ format.json { render :show, status: :ok, location: @person }
+ else
+ format.html { render :edit, status: :unprocessable_content }
+ format.json { render json: @person.errors, status: :unprocessable_content }
+ end
+ end
+ end
+
+ # DELETE /people/1 or /people/1.json
+ def destroy
+ @person.destroy!
+
+ respond_to do |format|
+ format.html { redirect_to people_path, notice: "Person was successfully destroyed.", status: :see_other }
+ format.json { head :no_content }
+ end
+ end
+
+ private
+ # Use callbacks to share common setup or constraints between actions.
+ def set_person
+ @person = Person.find(params.expect(:id))
+ end
+
+ # Only allow a list of trusted parameters through.
+ def person_params
+ params.expect(person: [ :first_name, :last_name, :date_of_birth, :social_security_number, :email, :relationship_id ])
+ end
+end
diff --git a/app/controllers/prescriptions_controller.rb b/app/controllers/prescriptions_controller.rb
new file mode 100644
index 0000000..2cfbf09
--- /dev/null
+++ b/app/controllers/prescriptions_controller.rb
@@ -0,0 +1,74 @@
+class PrescriptionsController < ApplicationController
+ before_action :set_prescription, only: %i[ show edit update destroy ]
+
+ # GET /prescriptions or /prescriptions.json
+ def index
+ # The index reads as a log: what is being taken now, newest first, with the
+ # stopped ones below it as history. includes avoids a medication and a form
+ # query per row.
+ @prescriptions = Prescription.includes(:medication, :medication_form)
+ .order(active: :desc, start_date: :desc, created_at: :desc)
+ end
+
+ # GET /prescriptions/1 or /prescriptions/1.json
+ def show
+ end
+
+ # GET /prescriptions/new
+ def new
+ @prescription = Prescription.new
+ end
+
+ # GET /prescriptions/1/edit
+ def edit
+ end
+
+ # POST /prescriptions or /prescriptions.json
+ def create
+ @prescription = Prescription.new(prescription_params)
+
+ respond_to do |format|
+ if @prescription.save
+ format.html { redirect_to @prescription, notice: "Prescription was successfully created." }
+ format.json { render :show, status: :created, location: @prescription }
+ else
+ format.html { render :new, status: :unprocessable_content }
+ format.json { render json: @prescription.errors, status: :unprocessable_content }
+ end
+ end
+ end
+
+ # PATCH/PUT /prescriptions/1 or /prescriptions/1.json
+ def update
+ respond_to do |format|
+ if @prescription.update(prescription_params)
+ format.html { redirect_to @prescription, notice: "Prescription was successfully updated.", status: :see_other }
+ format.json { render :show, status: :ok, location: @prescription }
+ else
+ format.html { render :edit, status: :unprocessable_content }
+ format.json { render json: @prescription.errors, status: :unprocessable_content }
+ end
+ end
+ end
+
+ # DELETE /prescriptions/1 or /prescriptions/1.json
+ def destroy
+ @prescription.destroy!
+
+ respond_to do |format|
+ format.html { redirect_to prescriptions_path, notice: "Prescription was successfully destroyed.", status: :see_other }
+ format.json { head :no_content }
+ end
+ end
+
+ private
+ # Use callbacks to share common setup or constraints between actions.
+ def set_prescription
+ @prescription = Prescription.find(params.expect(:id))
+ end
+
+ # Only allow a list of trusted parameters through.
+ def prescription_params
+ params.expect(prescription: [ :medication_id, :medication_form_id, :dosage, :frequency, :time_of_day, :prescribing_doctor, :purpose, :active, :start_date, :stop_date, :notes ])
+ end
+end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index de6be79..ec88b20 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -1,2 +1,54 @@
module ApplicationHelper
+ # True when the request is anywhere inside a section, not only on its index.
+ # current_page? matches the whole path, so it drops the active state the
+ # moment you open /people/1 or /people/new - which is exactly when knowing
+ # where you are matters most. The trailing slash keeps /medications from
+ # claiming /medication_types.
+ def section_current?(path)
+ request.path == path || request.path.start_with?("#{path}/")
+ end
+
+ # An empty table cell reads as a rendering fault. An em dash reads as "nobody
+ # recorded this", which is what a blank optional field actually means.
+ def or_dash(value)
+ value.presence || "—"
+ end
+
+ # The value aria-current wants, or nil to leave the attribute off entirely.
+ # link_to drops an aria value of nil rather than rendering aria-current="".
+ def aria_current_section(path)
+ "page" if section_current?(path)
+ end
+
+ # The sections that maintain reference data rather than a person's own
+ # records. Nobody using the app day to day adds a medication or invents a new
+ # dosage form; someone setting the app up does, once.
+ #
+ # This is the only list of them. The header's Admin link, the sub-nav under
+ # it and the cards on /admin all read from here, so a fourth lookup table
+ # gets added in one place and cannot appear in two of the three.
+ def admin_sections
+ [
+ { name: "Medications",
+ path: medications_path,
+ description: "The catalogue of drugs a prescription can point at, " \
+ "with the side effects to warn about." },
+ { name: "Medication types",
+ path: medication_types_path,
+ description: "How medications are grouped - the categories offered " \
+ "when adding one." },
+ { name: "Medication forms",
+ path: medication_forms_path,
+ description: "How a medication is taken: tablet, capsule, liquid, " \
+ "patch, and the rest." }
+ ]
+ end
+
+ # True anywhere in the administrative area: its landing page, or inside any
+ # section the landing page lists. Those sections keep their own top-level
+ # paths, so there is no /admin prefix to match on - the list is the test.
+ def admin_area?
+ section_current?(admin_path) ||
+ admin_sections.any? { |section| section_current?(section[:path]) }
+ end
end
diff --git a/app/helpers/home_helper.rb b/app/helpers/home_helper.rb
new file mode 100644
index 0000000..23de56a
--- /dev/null
+++ b/app/helpers/home_helper.rb
@@ -0,0 +1,2 @@
+module HomeHelper
+end
diff --git a/app/helpers/medication_types_helper.rb b/app/helpers/medication_types_helper.rb
new file mode 100644
index 0000000..84a67f0
--- /dev/null
+++ b/app/helpers/medication_types_helper.rb
@@ -0,0 +1,2 @@
+module MedicationTypesHelper
+end
diff --git a/app/helpers/medications_helper.rb b/app/helpers/medications_helper.rb
new file mode 100644
index 0000000..1cca854
--- /dev/null
+++ b/app/helpers/medications_helper.rb
@@ -0,0 +1,2 @@
+module MedicationsHelper
+end
diff --git a/app/helpers/people_helper.rb b/app/helpers/people_helper.rb
new file mode 100644
index 0000000..b682fbf
--- /dev/null
+++ b/app/helpers/people_helper.rb
@@ -0,0 +1,2 @@
+module PeopleHelper
+end
diff --git a/app/helpers/prescriptions_helper.rb b/app/helpers/prescriptions_helper.rb
new file mode 100644
index 0000000..262d2d1
--- /dev/null
+++ b/app/helpers/prescriptions_helper.rb
@@ -0,0 +1,38 @@
+module PrescriptionsHelper
+ # Two lines in one column rather than two columns. Most prescriptions have a
+ # start and no stop, so a Stopped column would be a mostly empty stripe down
+ # the log.
+ def prescription_started(prescription)
+ prescription_date(prescription.start_date) || "—"
+ end
+
+ # What has happened since it started. Every combination of active and
+ # stop_date says something, and all four say it out loud: a blank line under
+ # a Stopped badge is indistinguishable from a column that failed to render.
+ def prescription_stopped(prescription)
+ stopped = prescription_date(prescription.stop_date)
+
+ if prescription.active?
+ stopped ? "until #{stopped}" : "ongoing"
+ else
+ stopped ? "stopped #{stopped}" : "no stop date recorded"
+ end
+ end
+
+ # The word carries the meaning; the colour only agrees with it. A boolean
+ # printed raw ("true") is the one thing a caregiver scanning this cannot read
+ # at a glance.
+ def prescription_status(prescription)
+ if prescription.active?
+ tag.span "Active", class: "status status--active"
+ else
+ tag.span "Stopped", class: "status status--stopped"
+ end
+ end
+
+ # "Aug 1, 2026". Short enough to keep a log column narrow, spelled enough to
+ # sidestep the 8/1 vs 1/8 question.
+ def prescription_date(date)
+ date&.strftime("%b %-d, %Y")
+ end
+end
diff --git a/app/models/address.rb b/app/models/address.rb
new file mode 100644
index 0000000..d7153de
--- /dev/null
+++ b/app/models/address.rb
@@ -0,0 +1,3 @@
+class Address < ApplicationRecord
+ belongs_to :person
+end
diff --git a/app/models/medication.rb b/app/models/medication.rb
new file mode 100644
index 0000000..71f13dc
--- /dev/null
+++ b/app/models/medication.rb
@@ -0,0 +1,7 @@
+class Medication < ApplicationRecord
+ belongs_to :medication_type, optional: true
+
+ has_many :prescriptions, dependent: :restrict_with_error
+
+ validates :name, presence: true
+end
diff --git a/app/models/medication_form.rb b/app/models/medication_form.rb
new file mode 100644
index 0000000..1a57e97
--- /dev/null
+++ b/app/models/medication_form.rb
@@ -0,0 +1,5 @@
+class MedicationForm < ApplicationRecord
+ has_many :prescriptions, dependent: :nullify
+
+ validates :name, presence: true
+end
diff --git a/app/models/medication_type.rb b/app/models/medication_type.rb
new file mode 100644
index 0000000..2ccd64f
--- /dev/null
+++ b/app/models/medication_type.rb
@@ -0,0 +1,5 @@
+class MedicationType < ApplicationRecord
+ has_many :medications, dependent: :nullify
+
+ validates :name, presence: true
+end
diff --git a/app/models/person.rb b/app/models/person.rb
new file mode 100644
index 0000000..e8c2489
--- /dev/null
+++ b/app/models/person.rb
@@ -0,0 +1,5 @@
+class Person < ApplicationRecord
+ belongs_to :relationship
+
+ has_one :address, dependent: :destroy
+end
diff --git a/app/models/prescription.rb b/app/models/prescription.rb
new file mode 100644
index 0000000..5f0f0fd
--- /dev/null
+++ b/app/models/prescription.rb
@@ -0,0 +1,4 @@
+class Prescription < ApplicationRecord
+ belongs_to :medication
+ belongs_to :medication_form, optional: true
+end
diff --git a/app/models/relationship.rb b/app/models/relationship.rb
new file mode 100644
index 0000000..3cabc44
--- /dev/null
+++ b/app/models/relationship.rb
@@ -0,0 +1,2 @@
+class Relationship < ApplicationRecord
+end
diff --git a/app/models/user.rb b/app/models/user.rb
new file mode 100644
index 0000000..4f14a8d
--- /dev/null
+++ b/app/models/user.rb
@@ -0,0 +1,7 @@
+class User < ApplicationRecord
+ # Include default devise modules. Others available are:
+ # :omniauthable
+ devise :database_authenticatable, :registerable,
+ :recoverable, :rememberable, :validatable,
+ :lockable, :trackable, :confirmable
+end
diff --git a/app/views/addresses/_address.html.erb b/app/views/addresses/_address.html.erb
new file mode 100644
index 0000000..1f0a08b
--- /dev/null
+++ b/app/views/addresses/_address.html.erb
@@ -0,0 +1,12 @@
+
+ The reference data behind prescriptions. These lists are set up once and
+ edited rarely - the people using the app day to day pick from them rather
+ than add to them.
+
+
+
+ <% admin_sections.each do |section| %>
+
+ <%# The heading carries the only link, and it stretches over the whole
+ card - so the card is clickable without a second link to the same
+ place for a screen reader to read out twice. %>
+
+<% end %>
+
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index 58c8657..b6cad65 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -1,11 +1,17 @@
-
+
- <%= content_for(:title) || "Alongwithyou" %>
+ <%= content_for(:title) || "Along With You" %>
-
+
+
+ <%# Tells the browser to paint its own furniture - scrollbars, date pickers,
+ checkboxes - in whichever scheme the OS is set to, so they match the
+ palette in application.css rather than staying stubbornly light. %>
+
+
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
@@ -24,6 +30,61 @@
- <%= yield %>
+ <%# First thing in the tab order, hidden until focused. Without it, a
+ keyboard user tabs the whole nav on every page before reaching content. %>
+ Skip to content
+
+
+
+ <%= link_to "Show this prescription", @prescription %> |
+ <%= link_to "Back to prescriptions", prescriptions_path %>
+
diff --git a/app/views/prescriptions/index.html.erb b/app/views/prescriptions/index.html.erb
new file mode 100644
index 0000000..32ca691
--- /dev/null
+++ b/app/views/prescriptions/index.html.erb
@@ -0,0 +1,52 @@
+
<%= notice %>
+
+<% content_for :title, "Prescriptions" %>
+
+
Prescriptions
+
+<% if @prescriptions.any? %>
+ <%# The wrapper, not the table, is what scrolls on a narrow screen. tabindex
+ makes that scroll reachable without a mouse, and the role/label keep a
+ focusable div from being announced as an unnamed group. %>
+
+
+
+
+
Medication
+
Dosage
+
Frequency
+
Time of day
+
Prescribing doctor
+
Started
+
Status
+
+
+
+
+ <% @prescriptions.each do |prescription| %>
+
+
+ <%= link_to prescription.medication.name, prescription %>
+ <% if prescription.medication_form %>
+ <%= prescription.medication_form.name %>
+ <% end %>
+
+ <%= link_to "Back to prescriptions", prescriptions_path %>
+
diff --git a/app/views/prescriptions/show.html.erb b/app/views/prescriptions/show.html.erb
new file mode 100644
index 0000000..efa49f9
--- /dev/null
+++ b/app/views/prescriptions/show.html.erb
@@ -0,0 +1,10 @@
+
<%= notice %>
+
+<%= render @prescription %>
+
+
+ <%= link_to "Edit this prescription", edit_prescription_path(@prescription) %> |
+ <%= link_to "Back to prescriptions", prescriptions_path %>
+
+ <%= button_to "Destroy this prescription", @prescription, method: :delete %>
+
diff --git a/app/views/prescriptions/show.json.jbuilder b/app/views/prescriptions/show.json.jbuilder
new file mode 100644
index 0000000..d70e156
--- /dev/null
+++ b/app/views/prescriptions/show.json.jbuilder
@@ -0,0 +1 @@
+json.partial! "prescriptions/prescription", prescription: @prescription
diff --git a/app/views/shared/_admin_nav.html.erb b/app/views/shared/_admin_nav.html.erb
new file mode 100644
index 0000000..7024434
--- /dev/null
+++ b/app/views/shared/_admin_nav.html.erb
@@ -0,0 +1,20 @@
+<%# The second nav band, shown by the layout on every page of the
+ administrative area. It is what makes /medications feel like part of a
+ place rather than another top-level section: the header's Admin link stays
+ lit, and this row says which admin section you are in.
+
+ "Overview" is here rather than as a heading link because it is one of the
+ destinations - from a medication form, /admin is somewhere you go back to. %>
+
diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb
new file mode 100644
index 0000000..75117b6
--- /dev/null
+++ b/config/initializers/devise.rb
@@ -0,0 +1,316 @@
+# frozen_string_literal: true
+
+# Assuming you have not yet modified this file, each configuration option below
+# is set to its default value. Note that some are commented out while others
+# are not: uncommented lines are intended to protect your configuration from
+# breaking changes in upgrades (i.e., in the event that future versions of
+# Devise change the default values for those options).
+#
+# Use this hook to configure devise mailer, warden hooks and so forth.
+# Many of these configuration options can be set straight in your model.
+Devise.setup do |config|
+ # The secret key used by Devise. Devise uses this key to generate
+ # random tokens. Changing this key will render invalid all existing
+ # confirmation, reset password and unlock tokens in the database.
+ # Devise will use the `secret_key_base` as its `secret_key`
+ # by default. You can change it below and use your own secret key.
+ # config.secret_key = 'be48cc765be7e2ec33ba15d2a1fbdac62ecd68ea971d1294f8bfaf1dc502921adcbb9a42180741dfe89dccc80c2a1d9d4943cc6c288ce8a6443cb404505e22b3'
+
+ # ==> Controller configuration
+ # Configure the parent class to the devise controllers.
+ # config.parent_controller = 'DeviseController'
+
+ # ==> Mailer Configuration
+ # Configure the e-mail address which will be shown in Devise::Mailer,
+ # note that it will be overwritten if you use your own mailer class
+ # with default "from" parameter.
+ config.mailer_sender = "please-change-me-at-config-initializers-devise@example.com"
+
+ # Configure the class responsible to send e-mails.
+ # config.mailer = 'Devise::Mailer'
+
+ # Configure the parent class responsible to send e-mails.
+ # config.parent_mailer = 'ActionMailer::Base'
+
+ # ==> ORM configuration
+ # Load and configure the ORM. Supports :active_record (default) and
+ # :mongoid (bson_ext recommended) by default. Other ORMs may be
+ # available as additional gems.
+ require "devise/orm/active_record"
+
+ # ==> Configuration for any authentication mechanism
+ # Configure which keys are used when authenticating a user. The default is
+ # just :email. You can configure it to use [:username, :subdomain], so for
+ # authenticating a user, both parameters are required. Remember that those
+ # parameters are used only when authenticating and not when retrieving from
+ # session. If you need permissions, you should implement that in a before filter.
+ # You can also supply a hash where the value is a boolean determining whether
+ # or not authentication should be aborted when the value is not present.
+ # config.authentication_keys = [:email]
+
+ # Configure parameters from the request object used for authentication. Each entry
+ # given should be a request method and it will automatically be passed to the
+ # find_for_authentication method and considered in your model lookup. For instance,
+ # if you set :request_keys to [:subdomain], :subdomain will be used on authentication.
+ # The same considerations mentioned for authentication_keys also apply to request_keys.
+ # config.request_keys = []
+
+ # Configure which authentication keys should be case-insensitive.
+ # These keys will be downcased upon creating or modifying a user and when used
+ # to authenticate or find a user. Default is :email.
+ config.case_insensitive_keys = [ :email ]
+
+ # Configure which authentication keys should have whitespace stripped.
+ # These keys will have whitespace before and after removed upon creating or
+ # modifying a user and when used to authenticate or find a user. Default is :email.
+ config.strip_whitespace_keys = [ :email ]
+
+ # Tell if authentication through request.params is enabled. True by default.
+ # It can be set to an array that will enable params authentication only for the
+ # given strategies, for example, `config.params_authenticatable = [:database]` will
+ # enable it only for database (email + password) authentication.
+ # config.params_authenticatable = true
+
+ # Tell if authentication through HTTP Auth is enabled. False by default.
+ # It can be set to an array that will enable http authentication only for the
+ # given strategies, for example, `config.http_authenticatable = [:database]` will
+ # enable it only for database authentication.
+ # For API-only applications to support authentication "out-of-the-box", you will likely want to
+ # enable this with :database unless you are using a custom strategy.
+ # The supported strategies are:
+ # :database = Support basic authentication with authentication key + password
+ # config.http_authenticatable = false
+
+ # If 401 status code should be returned for AJAX requests. True by default.
+ # config.http_authenticatable_on_xhr = true
+
+ # The realm used in Http Basic Authentication. 'Application' by default.
+ # config.http_authentication_realm = 'Application'
+
+ # It will change confirmation, password recovery and other workflows
+ # to behave the same regardless if the e-mail provided was right or wrong.
+ # Does not affect registerable.
+ # config.paranoid = true
+
+ # By default Devise will store the user in session. You can skip storage for
+ # particular strategies by setting this option.
+ # Notice that if you are skipping storage for all authentication paths, you
+ # may want to disable generating routes to Devise's sessions controller by
+ # passing skip: :sessions to `devise_for` in your config/routes.rb
+ config.skip_session_storage = [ :http_auth ]
+
+ # By default, Devise cleans up the CSRF token on authentication to
+ # avoid CSRF token fixation attacks. This means that, when using AJAX
+ # requests for sign in and sign up, you need to get a new CSRF token
+ # from the server. You can disable this option at your own risk.
+ # config.clean_up_csrf_token_on_authentication = true
+
+ # When false, Devise will not attempt to reload routes on eager load.
+ # This can reduce the time taken to boot the app but if your application
+ # requires the Devise mappings to be loaded during boot time the application
+ # won't boot properly.
+ # config.reload_routes = true
+
+ # ==> Configuration for :database_authenticatable
+ # For bcrypt, this is the cost for hashing the password and defaults to 12. If
+ # using other algorithms, it sets how many times you want the password to be hashed.
+ # The number of stretches used for generating the hashed password are stored
+ # with the hashed password. This allows you to change the stretches without
+ # invalidating existing passwords.
+ #
+ # Limiting the stretches to just one in testing will increase the performance of
+ # your test suite dramatically. However, it is STRONGLY RECOMMENDED to not use
+ # a value less than 10 in other environments. Note that, for bcrypt (the default
+ # algorithm), the cost increases exponentially with the number of stretches (e.g.
+ # a value of 20 is already extremely slow: approx. 60 seconds for 1 calculation).
+ config.stretches = Rails.env.test? ? 1 : 12
+
+ # Set up a pepper to generate the hashed password.
+ # config.pepper = '127706bcb62f32c2160abb282491c2810ecf01a418769b330824734bb3a5bbf935ef806c4f977db9681c9aaa31c01e773ee372c40f94b5326690c7d9c7c37c6b'
+
+ # Send a notification to the original email when the user's email is changed.
+ # config.send_email_changed_notification = false
+
+ # Send a notification email when the user's password is changed.
+ # config.send_password_change_notification = false
+
+ # ==> Configuration for :confirmable
+ # A period that the user is allowed to access the website even without
+ # confirming their account. For instance, if set to 2.days, the user will be
+ # able to access the website for two days without confirming their account,
+ # access will be blocked just in the third day.
+ # You can also set it to nil, which will allow the user to access the website
+ # without confirming their account.
+ # Default is 0.days, meaning the user cannot access the website without
+ # confirming their account.
+ config.allow_unconfirmed_access_for = nil
+
+ # A period that the user is allowed to confirm their account before their
+ # token becomes invalid. For example, if set to 3.days, the user can confirm
+ # their account within 3 days after the mail was sent, but on the fourth day
+ # their account can't be confirmed with the token any more.
+ # Default is nil, meaning there is no restriction on how long a user can take
+ # before confirming their account.
+ # config.confirm_within = 3.days
+
+ # If true, requires any email changes to be confirmed (exactly the same way as
+ # initial account confirmation) to be applied. Requires additional unconfirmed_email
+ # db field (see migrations). Until confirmed, new email is stored in
+ # unconfirmed_email column, and copied to email column on successful confirmation.
+ # Also, when used in conjunction with `send_email_changed_notification`,
+ # the notification is sent to the original email when the change is requested,
+ # not when the unconfirmed email is confirmed.
+ config.reconfirmable = true
+
+ # Defines which key will be used when confirming an account
+ # config.confirmation_keys = [:email]
+
+ # ==> Configuration for :rememberable
+ # The time the user will be remembered without asking for credentials again.
+ # config.remember_for = 2.weeks
+
+ # Invalidates all the remember me tokens when the user signs out.
+ config.expire_all_remember_me_on_sign_out = true
+
+ # If true, extends the user's remember period when remembered via cookie.
+ # config.extend_remember_period = false
+
+ # Options to be passed to the created cookie. For instance, you can set
+ # secure: true in order to force SSL only cookies.
+ # config.rememberable_options = {}
+
+ # ==> Configuration for :validatable
+ # Range for password length.
+ config.password_length = 6..128
+
+ # Email regex used to validate email formats. It simply asserts that
+ # one (and only one) @ exists in the given string. This is mainly
+ # to give user feedback and not to assert the e-mail validity.
+ config.email_regexp = /\A[^@\s]+@[^@\s]+\z/
+
+ # ==> Configuration for :timeoutable
+ # The time you want to timeout the user session without activity. After this
+ # time the user will be asked for credentials again. Default is 30 minutes.
+ # config.timeout_in = 30.minutes
+
+ # ==> Configuration for :lockable
+ # Defines which strategy will be used to lock an account.
+ # :failed_attempts = Locks an account after a number of failed attempts to sign in.
+ # :none = No lock strategy. You should handle locking by yourself.
+ # config.lock_strategy = :failed_attempts
+
+ # Defines which key will be used when locking and unlocking an account
+ # config.unlock_keys = [:email]
+
+ # Defines which strategy will be used to unlock an account.
+ # :email = Sends an unlock link to the user email
+ # :time = Re-enables login after a certain amount of time (see :unlock_in below)
+ # :both = Enables both strategies
+ # :none = No unlock strategy. You should handle unlocking by yourself.
+ # config.unlock_strategy = :both
+
+ # Number of authentication tries before locking an account if lock_strategy
+ # is failed attempts.
+ # config.maximum_attempts = 20
+
+ # Time interval to unlock the account if :time is enabled as unlock_strategy.
+ # config.unlock_in = 1.hour
+
+ # Warn on the last attempt before the account is locked.
+ # config.last_attempt_warning = true
+
+ # ==> Configuration for :recoverable
+ #
+ # Defines which key will be used when recovering the password for an account
+ config.reset_password_keys = [ :email ]
+
+ # Time interval you can reset your password with a reset password key.
+ # Don't put a too small interval or your users won't have the time to
+ # change their passwords.
+ config.reset_password_within = 6.hours
+
+ # When set to false, does not sign a user in automatically after their password is
+ # reset. Defaults to true, so a user is signed in automatically after a reset.
+ # config.sign_in_after_reset_password = true
+
+ # ==> Configuration for :encryptable
+ # Allow you to use another hashing or encryption algorithm besides bcrypt (default).
+ # You can use :sha1, :sha512 or algorithms from others authentication tools as
+ # :clearance_sha1, :authlogic_sha512 (then you should set stretches above to 20
+ # for default behavior) and :restful_authentication_sha1 (then you should set
+ # stretches to 10, and copy REST_AUTH_SITE_KEY to pepper).
+ #
+ # Require the `devise-encryptable` gem when using anything other than bcrypt
+ # config.encryptor = :sha512
+
+ # ==> Scopes configuration
+ # Turn scoped views on. Before rendering "sessions/new", it will first check for
+ # "users/sessions/new". It's turned off by default because it's slower if you
+ # are using only default views.
+ # config.scoped_views = false
+
+ # Configure the default scope given to Warden. By default it's the first
+ # devise role declared in your routes (usually :user).
+ # config.default_scope = :user
+
+ # Set this configuration to false if you want /users/sign_out to sign out
+ # only the current scope. By default, Devise signs out all scopes.
+ # config.sign_out_all_scopes = true
+
+ # ==> Navigation configuration
+ # Lists the formats that should be treated as navigational. Formats like
+ # :html should redirect to the sign in page when the user does not have
+ # access, but formats like :xml or :json, should return 401.
+ #
+ # If you have any extra navigational formats, like :iphone or :mobile, you
+ # should add them to the navigational formats lists.
+ #
+ # The "*/*" below is required to match Internet Explorer requests.
+ # config.navigational_formats = ['*/*', :html, :turbo_stream]
+
+ # The default HTTP method used to sign out a resource. Default is :delete.
+ config.sign_out_via = :delete
+
+ # ==> OmniAuth
+ # Add a new OmniAuth provider. Check the wiki for more information on setting
+ # up on your models and hooks.
+ # config.omniauth :github, 'APP_ID', 'APP_SECRET', scope: 'user,public_repo'
+
+ # ==> Warden configuration
+ # If you want to use other strategies, that are not supported by Devise, or
+ # change the failure app, you can configure them inside the config.warden block.
+ #
+ # config.warden do |warden_config|
+ # warden_config.intercept_401 = false
+ # warden_config.default_strategies(scope: :user).unshift :some_external_strategy
+ # end
+
+ # ==> Mountable engine configurations
+ # When using Devise inside an engine, let's call it `MyEngine`, and this engine
+ # is mountable, there are some extra configurations to be taken into account.
+ # The following options are available, assuming the engine is mounted as:
+ #
+ # mount MyEngine, at: '/my_engine'
+ #
+ # The router that invoked `devise_for`, in the example above, would be:
+ # config.router_name = :my_engine
+ #
+ # When using OmniAuth, Devise cannot automatically set OmniAuth path,
+ # so you need to do it manually. For the users scope, it would be:
+ # config.omniauth_path_prefix = '/my_engine/users/auth'
+
+ # ==> Hotwire/Turbo configuration
+ # When using Devise with Hotwire/Turbo, the http status for error responses
+ # and some redirects must match the following. The default in Devise for existing
+ # apps is `200 OK` and `302 Found` respectively, but new apps are generated with
+ # these new defaults that match Hotwire/Turbo behavior.
+ # Note: These might become the new default in future versions of Devise.
+ config.responder.error_status = :unprocessable_content
+ config.responder.redirect_status = :see_other
+
+ # ==> Configuration for :registerable
+
+ # When set to false, does not sign a user in automatically after their password is
+ # changed. Defaults to true, so a user is signed in automatically after changing a password.
+ config.sign_in_after_change_password = true
+end
diff --git a/config/locales/devise.en.yml b/config/locales/devise.en.yml
new file mode 100644
index 0000000..260e1c4
--- /dev/null
+++ b/config/locales/devise.en.yml
@@ -0,0 +1,65 @@
+# Additional translations at https://github.com/heartcombo/devise/wiki/I18n
+
+en:
+ devise:
+ confirmations:
+ confirmed: "Your email address has been successfully confirmed."
+ send_instructions: "You will receive an email with instructions for how to confirm your email address in a few minutes."
+ send_paranoid_instructions: "If your email address exists in our database, you will receive an email with instructions for how to confirm your email address in a few minutes."
+ failure:
+ already_authenticated: "You are already signed in."
+ inactive: "Your account is not activated yet."
+ invalid: "Invalid %{authentication_keys} or password."
+ locked: "Your account is locked."
+ last_attempt: "You have one more attempt before your account is locked."
+ not_found_in_database: "Invalid %{authentication_keys} or password."
+ timeout: "Your session expired. Please sign in again to continue."
+ unauthenticated: "You need to sign in or sign up before continuing."
+ unconfirmed: "You have to confirm your email address before continuing."
+ mailer:
+ confirmation_instructions:
+ subject: "Confirmation instructions"
+ reset_password_instructions:
+ subject: "Reset password instructions"
+ unlock_instructions:
+ subject: "Unlock instructions"
+ email_changed:
+ subject: "Email Changed"
+ password_change:
+ subject: "Password Changed"
+ omniauth_callbacks:
+ failure: "Could not authenticate you from %{kind} because \"%{reason}\"."
+ success: "Successfully authenticated from %{kind} account."
+ passwords:
+ no_token: "You can't access this page without coming from a password reset email. If you do come from a password reset email, please make sure you used the full URL provided."
+ send_instructions: "You will receive an email with instructions on how to reset your password in a few minutes."
+ send_paranoid_instructions: "If your email address exists in our database, you will receive a password recovery link at your email address in a few minutes."
+ updated: "Your password has been changed successfully. You are now signed in."
+ updated_not_active: "Your password has been changed successfully."
+ registrations:
+ destroyed: "Bye! Your account has been successfully cancelled. We hope to see you again soon."
+ signed_up: "Welcome! You have signed up successfully."
+ signed_up_but_inactive: "You have signed up successfully. However, we could not sign you in because your account is not yet activated."
+ signed_up_but_locked: "You have signed up successfully. However, we could not sign you in because your account is locked."
+ signed_up_but_unconfirmed: "A message with a confirmation link has been sent to your email address. Please follow the link to activate your account."
+ update_needs_confirmation: "You updated your account successfully, but we need to verify your new email address. Please check your email and follow the confirmation link to confirm your new email address."
+ updated: "Your account has been updated successfully."
+ updated_but_not_signed_in: "Your account has been updated successfully, but since your password was changed, you need to sign in again."
+ sessions:
+ signed_in: "Signed in successfully."
+ signed_out: "Signed out successfully."
+ already_signed_out: "Signed out successfully."
+ unlocks:
+ send_instructions: "You will receive an email with instructions for how to unlock your account in a few minutes."
+ send_paranoid_instructions: "If your account exists, you will receive an email with instructions for how to unlock it in a few minutes."
+ unlocked: "Your account has been unlocked successfully. Please sign in to continue."
+ errors:
+ messages:
+ already_confirmed: "was already confirmed, please try signing in"
+ confirmation_period_expired: "needs to be confirmed within %{period}, please request a new one"
+ expired: "has expired, please request a new one"
+ not_found: "not found"
+ not_locked: "was not locked"
+ not_saved:
+ one: "1 error prohibited this %{resource} from being saved:"
+ other: "%{count} errors prohibited this %{resource} from being saved:"
diff --git a/config/routes.rb b/config/routes.rb
index 4368cb6..683683e 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1,4 +1,12 @@
Rails.application.routes.draw do
+ devise_for :users
+ resources :medications
+ resources :medication_forms
+ resources :medication_types
+ resources :prescriptions
+ resources :people do
+ resource :address, only: [ :show, :create, :update, :destroy ]
+ end
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
# JSON API consumed by the Expo app in mobile/.
@@ -23,6 +31,13 @@
# get "manifest" => "rails/pwa#manifest", as: :pwa_manifest
# get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker
+ # The administrative area. The reference-data resources it covers keep their
+ # own top-level paths - this is only the landing page that gathers them, so
+ # the header can carry one Admin link instead of one per lookup table. See
+ # ApplicationHelper#admin_sections for the list it shows.
+ get "admin" => "admin#index", as: :admin
+
# Defines the root path route ("/")
- # root "posts#index"
+ root "home#index"
+ get "world" => "home#world"
end
diff --git a/db/migrate/20260828183715_create_people.rb b/db/migrate/20260828183715_create_people.rb
new file mode 100644
index 0000000..159c2c8
--- /dev/null
+++ b/db/migrate/20260828183715_create_people.rb
@@ -0,0 +1,14 @@
+class CreatePeople < ActiveRecord::Migration[8.1]
+ def change
+ create_table :people do |t|
+ t.string :first_name
+ t.string :last_name
+ t.date :date_of_birth
+ t.string :social_security_number
+ t.string :email
+ t.belongs_to :relationship, null: false, foreign_key: true
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20260828184216_create_medication_types.rb b/db/migrate/20260828184216_create_medication_types.rb
new file mode 100644
index 0000000..9a71d5d
--- /dev/null
+++ b/db/migrate/20260828184216_create_medication_types.rb
@@ -0,0 +1,9 @@
+class CreateMedicationTypes < ActiveRecord::Migration[8.1]
+ def change
+ create_table :medication_types do |t|
+ t.string :name
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20260828192329_create_addresses.rb b/db/migrate/20260828192329_create_addresses.rb
new file mode 100644
index 0000000..0b35579
--- /dev/null
+++ b/db/migrate/20260828192329_create_addresses.rb
@@ -0,0 +1,10 @@
+class CreateAddresses < ActiveRecord::Migration[8.1]
+ def change
+ create_table :addresses do |t|
+ t.string :city
+ t.string :state
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20260828193338_devise_create_users.rb b/db/migrate/20260828193338_devise_create_users.rb
new file mode 100644
index 0000000..b237e7a
--- /dev/null
+++ b/db/migrate/20260828193338_devise_create_users.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+class DeviseCreateUsers < ActiveRecord::Migration[8.1]
+ def change
+ create_table :users do |t|
+ ## Database authenticatable
+ t.string :email, null: false
+ t.string :encrypted_password, null: false
+
+ ## Recoverable
+ t.string :reset_password_token
+ t.datetime :reset_password_sent_at
+
+ ## Rememberable
+ t.datetime :remember_created_at
+
+ ## Trackable
+ t.integer :sign_in_count, default: 0, null: false
+ t.datetime :current_sign_in_at
+ t.datetime :last_sign_in_at
+ t.string :current_sign_in_ip
+ t.string :last_sign_in_ip
+
+ ## Confirmable
+ t.string :confirmation_token
+ t.datetime :confirmed_at
+ t.datetime :confirmation_sent_at
+ t.string :unconfirmed_email # Only if using reconfirmable
+
+ ## Lockable
+ t.integer :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts
+ t.string :unlock_token # Only if unlock strategy is :email or :both
+ t.datetime :locked_at
+
+
+ t.timestamps null: false
+ end
+
+ add_index :users, :email, unique: true
+ add_index :users, :reset_password_token, unique: true
+ add_index :users, :confirmation_token, unique: true
+ add_index :users, :unlock_token, unique: true
+ end
+end
diff --git a/db/migrate/20260829113658_create_medications.rb b/db/migrate/20260829113658_create_medications.rb
new file mode 100644
index 0000000..613848b
--- /dev/null
+++ b/db/migrate/20260829113658_create_medications.rb
@@ -0,0 +1,20 @@
+class CreateMedications < ActiveRecord::Migration[8.1]
+ def change
+ create_table :medications do |t|
+ t.string :name
+ t.belongs_to :medication_type, null: false, foreign_key: true
+ t.boolean :current
+ t.string :dosage
+ t.string :frequency
+ t.string :time_of_day
+ t.string :form
+ t.text :purpose
+ t.date :start_date
+ t.date :stop_date
+ t.string :refill
+ t.text :notes
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20260829113836_create_relationships.rb b/db/migrate/20260829113836_create_relationships.rb
new file mode 100644
index 0000000..e81702b
--- /dev/null
+++ b/db/migrate/20260829113836_create_relationships.rb
@@ -0,0 +1,9 @@
+class CreateRelationships < ActiveRecord::Migration[8.1]
+ def change
+ create_table :relationships do |t|
+ t.string :name
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20260829145325_add_person_ref_to_address.rb b/db/migrate/20260829145325_add_person_ref_to_address.rb
new file mode 100644
index 0000000..2367ad6
--- /dev/null
+++ b/db/migrate/20260829145325_add_person_ref_to_address.rb
@@ -0,0 +1,5 @@
+class AddPersonRefToAddress < ActiveRecord::Migration[8.1]
+ def change
+ add_reference :addresses, :person, null: false, foreign_key: true
+ end
+end
diff --git a/db/migrate/20260829203813_create_medication_forms.rb b/db/migrate/20260829203813_create_medication_forms.rb
new file mode 100644
index 0000000..92b31dc
--- /dev/null
+++ b/db/migrate/20260829203813_create_medication_forms.rb
@@ -0,0 +1,9 @@
+class CreateMedicationForms < ActiveRecord::Migration[8.1]
+ def change
+ create_table :medication_forms do |t|
+ t.string :name
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20260829203818_create_prescriptions.rb b/db/migrate/20260829203818_create_prescriptions.rb
new file mode 100644
index 0000000..46398a6
--- /dev/null
+++ b/db/migrate/20260829203818_create_prescriptions.rb
@@ -0,0 +1,19 @@
+class CreatePrescriptions < ActiveRecord::Migration[8.1]
+ def change
+ create_table :prescriptions do |t|
+ t.belongs_to :medication, null: false, foreign_key: true
+ t.belongs_to :medication_form, foreign_key: true
+ t.string :dosage
+ t.string :frequency
+ t.string :time_of_day
+ t.string :prescribing_doctor
+ t.text :purpose
+ t.boolean :active, default: true, null: false
+ t.date :start_date
+ t.date :stop_date
+ t.text :notes
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20260829203900_move_prescription_fields_off_medications.rb b/db/migrate/20260829203900_move_prescription_fields_off_medications.rb
new file mode 100644
index 0000000..f5a2062
--- /dev/null
+++ b/db/migrate/20260829203900_move_prescription_fields_off_medications.rb
@@ -0,0 +1,22 @@
+class MovePrescriptionFieldsOffMedications < ActiveRecord::Migration[8.1]
+ def change
+ # A medication is now the catalog entry (what the drug is); how a patient
+ # takes it lives on prescriptions. See issue #164.
+ remove_column :medications, :current, :boolean
+ remove_column :medications, :dosage, :string
+ remove_column :medications, :frequency, :string
+ remove_column :medications, :time_of_day, :string
+ remove_column :medications, :form, :string
+ remove_column :medications, :purpose, :text
+ remove_column :medications, :start_date, :date
+ remove_column :medications, :stop_date, :date
+ remove_column :medications, :refill, :string
+ remove_column :medications, :notes, :text
+
+ add_column :medications, :side_effects, :text
+
+ # The seed catalog in #164 lists no type for each medication, so a
+ # medication can exist before anyone classifies it.
+ change_column_null :medications, :medication_type_id, true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 8e5e9b6..84a1e9a 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,11 +10,109 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2026_08_02_160512) do
+ActiveRecord::Schema[8.1].define(version: 2026_08_29_203900) do
+ create_table "addresses", force: :cascade do |t|
+ t.string "city"
+ t.datetime "created_at", null: false
+ t.integer "person_id", null: false
+ t.string "state"
+ t.datetime "updated_at", null: false
+ t.index ["person_id"], name: "index_addresses_on_person_id"
+ end
+
+ create_table "medication_forms", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.string "name"
+ t.datetime "updated_at", null: false
+ end
+
+ create_table "medication_types", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.string "name"
+ t.datetime "updated_at", null: false
+ end
+
+ create_table "medications", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.integer "medication_type_id"
+ t.string "name"
+ t.text "side_effects"
+ t.datetime "updated_at", null: false
+ t.index ["medication_type_id"], name: "index_medications_on_medication_type_id"
+ end
+
+ create_table "people", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.date "date_of_birth"
+ t.string "email"
+ t.string "first_name"
+ t.string "last_name"
+ t.integer "relationship_id", null: false
+ t.string "social_security_number"
+ t.datetime "updated_at", null: false
+ t.index ["relationship_id"], name: "index_people_on_relationship_id"
+ end
+
+ create_table "prescriptions", force: :cascade do |t|
+ t.boolean "active", default: true, null: false
+ t.datetime "created_at", null: false
+ t.string "dosage"
+ t.string "frequency"
+ t.integer "medication_form_id"
+ t.integer "medication_id", null: false
+ t.text "notes"
+ t.string "prescribing_doctor"
+ t.text "purpose"
+ t.date "start_date"
+ t.date "stop_date"
+ t.string "time_of_day"
+ t.datetime "updated_at", null: false
+ t.index ["medication_form_id"], name: "index_prescriptions_on_medication_form_id"
+ t.index ["medication_id"], name: "index_prescriptions_on_medication_id"
+ end
+
+ create_table "relationships", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.string "name"
+ t.datetime "updated_at", null: false
+ end
+
create_table "tasks", force: :cascade do |t|
t.boolean "completed", default: false, null: false
t.datetime "created_at", null: false
t.string "title", null: false
t.datetime "updated_at", null: false
end
+
+ create_table "users", force: :cascade do |t|
+ t.datetime "confirmation_sent_at"
+ t.string "confirmation_token"
+ t.datetime "confirmed_at"
+ t.datetime "created_at", null: false
+ t.datetime "current_sign_in_at"
+ t.string "current_sign_in_ip"
+ t.string "email", null: false
+ t.string "encrypted_password", null: false
+ t.integer "failed_attempts", default: 0, null: false
+ t.datetime "last_sign_in_at"
+ t.string "last_sign_in_ip"
+ t.datetime "locked_at"
+ t.datetime "remember_created_at"
+ t.datetime "reset_password_sent_at"
+ t.string "reset_password_token"
+ t.integer "sign_in_count", default: 0, null: false
+ t.string "unconfirmed_email"
+ t.string "unlock_token"
+ t.datetime "updated_at", null: false
+ t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
+ t.index ["email"], name: "index_users_on_email", unique: true
+ t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
+ t.index ["unlock_token"], name: "index_users_on_unlock_token", unique: true
+ end
+
+ add_foreign_key "addresses", "people"
+ add_foreign_key "medications", "medication_types"
+ add_foreign_key "people", "relationships"
+ add_foreign_key "prescriptions", "medication_forms"
+ add_foreign_key "prescriptions", "medications"
end
diff --git a/db/seeds.rb b/db/seeds.rb
index 5fb2308..f7802de 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -9,3 +9,52 @@
{ title: "Replace Task with a real model", completed: false }
])
end
+
+# The medication catalog from issue #164. Looked up by name so re-seeding an
+# existing database tops it up instead of duplicating it.
+[
+ "Prescription",
+ "Over-the-counter",
+ "Vitamin or supplement",
+ "Herbal or natural"
+].each { |name| MedicationType.find_or_create_by!(name: name) }
+
+[
+ "Capsule",
+ "Liquid",
+ "Suspension",
+ "Syrup",
+ "Chewable tablet",
+ "Dissolvable tablet",
+ "Powder",
+ "Injection",
+ "Inhaler",
+ "Nebulizer solution",
+ "Patch",
+ "Cream",
+ "Ointment",
+ "Gel",
+ "Drops",
+ "Suppository",
+ "Spray"
+].each { |name| MedicationForm.find_or_create_by!(name: name) }
+
+{
+ "Lisinopril" => "Dry cough, dizziness, headache",
+ "Atorvastatin" => "Muscle aches, nausea, joint pain",
+ "Metformin" => "Nausea, diarrhea, stomach upset",
+ "Levothyroxine" => "Weight changes, insomnia, tremor",
+ "Amlodipine" => "Swollen ankles, flushing, fatigue",
+ "Metoprolol" => "Fatigue, dizziness, slow heartbeat",
+ "Omeprazole" => "Headache, gas, constipation",
+ "Albuterol" => "Jitteriness, rapid heartbeat, headache",
+ "Gabapentin" => "Drowsiness, dizziness, coordination problems",
+ "Sertraline" => "Nausea, insomnia, dry mouth",
+ "Ibuprofen" => "Stomach upset, heartburn, dizziness",
+ "Acetaminophen" => "Nausea, rash",
+ "Amoxicillin" => "Diarrhea, nausea, rash",
+ "Prednisone" => "Increased appetite, insomnia, mood changes",
+ "Warfarin" => "Easy bruising, bleeding, nausea"
+}.each do |name, side_effects|
+ Medication.find_or_create_by!(name: name) { |medication| medication.side_effects = side_effects }
+end
diff --git a/mise.toml b/mise.toml
new file mode 100644
index 0000000..dce2b74
--- /dev/null
+++ b/mise.toml
@@ -0,0 +1,3 @@
+[tools]
+node = "latest"
+ruby = "4.0.6"
diff --git a/test/controllers/addresses_controller_test.rb b/test/controllers/addresses_controller_test.rb
new file mode 100644
index 0000000..93dfabf
--- /dev/null
+++ b/test/controllers/addresses_controller_test.rb
@@ -0,0 +1,28 @@
+require "test_helper"
+
+class AddressesControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @address = addresses(:one)
+ @person = @address.person
+ end
+
+ test "should show address" do
+ get person_address_url(@person)
+ assert_response :success
+ end
+
+ test "should create address" do
+ @person.address.destroy
+ assert_difference("Address.count") do
+ post person_address_url(@person), params: { address: { city: @address.city, state: @address.state } }
+ end
+ assert_redirected_to person_address_url(@person)
+ end
+
+ test "should destroy address" do
+ assert_difference("Address.count", -1) do
+ delete person_address_url(@person)
+ end
+ assert_redirected_to person_url(@person)
+ end
+end
diff --git a/test/controllers/admin_controller_test.rb b/test/controllers/admin_controller_test.rb
new file mode 100644
index 0000000..6b075db
--- /dev/null
+++ b/test/controllers/admin_controller_test.rb
@@ -0,0 +1,43 @@
+require "test_helper"
+
+class AdminControllerTest < ActionDispatch::IntegrationTest
+ test "should get index" do
+ get admin_url
+ assert_response :success
+ end
+
+ # The landing page is generated from ApplicationHelper#admin_sections, so
+ # this fails if a section is added to the list without a working path helper.
+ test "index links to every admin section" do
+ get admin_url
+
+ assert_select "a[href=?]", medications_path
+ assert_select "a[href=?]", medication_types_path
+ assert_select "a[href=?]", medication_forms_path
+ end
+
+ # The point of the exercise: the reference-data sections are behind the one
+ # Admin link, not sitting in the main nav beside People and Prescriptions.
+ test "main nav carries a single admin link" do
+ get root_url
+
+ assert_select "nav.site-nav a[href=?]", admin_path
+ assert_select "nav.site-nav a[href=?]", medications_path, count: 0
+ assert_select "nav.admin-nav", count: 0
+ end
+
+ # The sub-nav is what makes /medications read as part of the admin area
+ # rather than another top-level section, so it has to follow you into one.
+ test "admin sub-nav shows inside an admin section" do
+ get medications_url
+
+ assert_select "nav.admin-nav a[href=?][aria-current=page]", medications_path
+ assert_select "nav.site-nav a[href=?][aria-current=page]", admin_path
+ end
+
+ test "admin sub-nav stays hidden outside the admin area" do
+ get people_url
+
+ assert_select "nav.admin-nav", count: 0
+ end
+end
diff --git a/test/controllers/home_controller_test.rb b/test/controllers/home_controller_test.rb
new file mode 100644
index 0000000..eac0e33
--- /dev/null
+++ b/test/controllers/home_controller_test.rb
@@ -0,0 +1,7 @@
+require "test_helper"
+
+class HomeControllerTest < ActionDispatch::IntegrationTest
+ # test "the truth" do
+ # assert true
+ # end
+end
diff --git a/test/controllers/medication_forms_controller_test.rb b/test/controllers/medication_forms_controller_test.rb
new file mode 100644
index 0000000..3d693f2
--- /dev/null
+++ b/test/controllers/medication_forms_controller_test.rb
@@ -0,0 +1,48 @@
+require "test_helper"
+
+class MedicationFormsControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @medication_form = medication_forms(:one)
+ end
+
+ test "should get index" do
+ get medication_forms_url
+ assert_response :success
+ end
+
+ test "should get new" do
+ get new_medication_form_url
+ assert_response :success
+ end
+
+ test "should create medication_form" do
+ assert_difference("MedicationForm.count") do
+ post medication_forms_url, params: { medication_form: { name: @medication_form.name } }
+ end
+
+ assert_redirected_to medication_form_url(MedicationForm.last)
+ end
+
+ test "should show medication_form" do
+ get medication_form_url(@medication_form)
+ assert_response :success
+ end
+
+ test "should get edit" do
+ get edit_medication_form_url(@medication_form)
+ assert_response :success
+ end
+
+ test "should update medication_form" do
+ patch medication_form_url(@medication_form), params: { medication_form: { name: @medication_form.name } }
+ assert_redirected_to medication_form_url(@medication_form)
+ end
+
+ test "should destroy medication_form" do
+ assert_difference("MedicationForm.count", -1) do
+ delete medication_form_url(@medication_form)
+ end
+
+ assert_redirected_to medication_forms_url
+ end
+end
diff --git a/test/controllers/medication_types_controller_test.rb b/test/controllers/medication_types_controller_test.rb
new file mode 100644
index 0000000..684c1a4
--- /dev/null
+++ b/test/controllers/medication_types_controller_test.rb
@@ -0,0 +1,48 @@
+require "test_helper"
+
+class MedicationTypesControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @medication_type = medication_types(:one)
+ end
+
+ test "should get index" do
+ get medication_types_url
+ assert_response :success
+ end
+
+ test "should get new" do
+ get new_medication_type_url
+ assert_response :success
+ end
+
+ test "should create medication_type" do
+ assert_difference("MedicationType.count") do
+ post medication_types_url, params: { medication_type: { name: @medication_type.name } }
+ end
+
+ assert_redirected_to medication_type_url(MedicationType.last)
+ end
+
+ test "should show medication_type" do
+ get medication_type_url(@medication_type)
+ assert_response :success
+ end
+
+ test "should get edit" do
+ get edit_medication_type_url(@medication_type)
+ assert_response :success
+ end
+
+ test "should update medication_type" do
+ patch medication_type_url(@medication_type), params: { medication_type: { name: @medication_type.name } }
+ assert_redirected_to medication_type_url(@medication_type)
+ end
+
+ test "should destroy medication_type" do
+ assert_difference("MedicationType.count", -1) do
+ delete medication_type_url(@medication_type)
+ end
+
+ assert_redirected_to medication_types_url
+ end
+end
diff --git a/test/controllers/medications_controller_test.rb b/test/controllers/medications_controller_test.rb
new file mode 100644
index 0000000..2b8834d
--- /dev/null
+++ b/test/controllers/medications_controller_test.rb
@@ -0,0 +1,66 @@
+require "test_helper"
+
+class MedicationsControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @medication = medications(:one)
+ end
+
+ test "should get index" do
+ get medications_url
+ assert_response :success
+ end
+
+ test "should get new" do
+ get new_medication_url
+ assert_response :success
+ end
+
+ test "should create medication" do
+ assert_difference("Medication.count") do
+ post medications_url, params: { medication: { name: "Metformin", medication_type_id: @medication.medication_type_id, side_effects: "Nausea, diarrhea, stomach upset" } }
+ end
+
+ assert_redirected_to medication_url(Medication.last)
+ end
+
+ test "should not create medication without a name" do
+ assert_no_difference("Medication.count") do
+ post medications_url, params: { medication: { name: "", side_effects: "Nausea" } }
+ end
+
+ assert_response :unprocessable_content
+ end
+
+ test "should show medication" do
+ get medication_url(@medication)
+ assert_response :success
+ end
+
+ test "should get edit" do
+ get edit_medication_url(@medication)
+ assert_response :success
+ end
+
+ test "should update medication" do
+ patch medication_url(@medication), params: { medication: { name: @medication.name, medication_type_id: @medication.medication_type_id, side_effects: @medication.side_effects } }
+ assert_redirected_to medication_url(@medication)
+ end
+
+ test "should destroy medication" do
+ unprescribed = medications(:three)
+
+ assert_difference("Medication.count", -1) do
+ delete medication_url(unprescribed)
+ end
+
+ assert_redirected_to medications_url
+ end
+
+ test "should not destroy medication that a prescription depends on" do
+ assert_no_difference("Medication.count") do
+ delete medication_url(@medication)
+ end
+
+ assert_redirected_to medication_url(@medication)
+ end
+end
diff --git a/test/controllers/people_controller_test.rb b/test/controllers/people_controller_test.rb
new file mode 100644
index 0000000..3dfc530
--- /dev/null
+++ b/test/controllers/people_controller_test.rb
@@ -0,0 +1,48 @@
+require "test_helper"
+
+class PeopleControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @person = people(:one)
+ end
+
+ test "should get index" do
+ get people_url
+ assert_response :success
+ end
+
+ test "should get new" do
+ get new_person_url
+ assert_response :success
+ end
+
+ test "should create person" do
+ assert_difference("Person.count") do
+ post people_url, params: { person: { date_of_birth: @person.date_of_birth, email: @person.email, first_name: @person.first_name, last_name: @person.last_name, relationship_id: @person.relationship_id, social_security_number: @person.social_security_number } }
+ end
+
+ assert_redirected_to person_url(Person.last)
+ end
+
+ test "should show person" do
+ get person_url(@person)
+ assert_response :success
+ end
+
+ test "should get edit" do
+ get edit_person_url(@person)
+ assert_response :success
+ end
+
+ test "should update person" do
+ patch person_url(@person), params: { person: { date_of_birth: @person.date_of_birth, email: @person.email, first_name: @person.first_name, last_name: @person.last_name, relationship_id: @person.relationship_id, social_security_number: @person.social_security_number } }
+ assert_redirected_to person_url(@person)
+ end
+
+ test "should destroy person" do
+ assert_difference("Person.count", -1) do
+ delete person_url(@person)
+ end
+
+ assert_redirected_to people_url
+ end
+end
diff --git a/test/controllers/prescriptions_controller_test.rb b/test/controllers/prescriptions_controller_test.rb
new file mode 100644
index 0000000..b24ff30
--- /dev/null
+++ b/test/controllers/prescriptions_controller_test.rb
@@ -0,0 +1,69 @@
+require "test_helper"
+
+class PrescriptionsControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @prescription = prescriptions(:one)
+ end
+
+ test "should get index" do
+ get prescriptions_url
+
+ assert_response :success
+ assert_select "table#prescriptions thead th", 7
+ assert_select "table#prescriptions tbody tr", Prescription.count
+ assert_select "##{dom_id(@prescription)} th[scope=row]", text: /#{@prescription.medication.name}/
+ end
+
+ test "index lists active prescriptions before stopped ones" do
+ get prescriptions_url
+
+ statuses = css_select("table#prescriptions tbody .status").map(&:text)
+ assert_equal statuses.sort_by { |status| status == "Active" ? 0 : 1 }, statuses
+ end
+
+ test "index shows an empty state when there is nothing to log" do
+ Prescription.delete_all
+
+ get prescriptions_url
+
+ assert_response :success
+ assert_select "table#prescriptions", false
+ assert_select "p", text: "No prescriptions yet."
+ end
+
+ test "should get new" do
+ get new_prescription_url
+ assert_response :success
+ end
+
+ test "should create prescription" do
+ assert_difference("Prescription.count") do
+ post prescriptions_url, params: { prescription: { active: @prescription.active, dosage: @prescription.dosage, frequency: @prescription.frequency, medication_form_id: @prescription.medication_form_id, medication_id: @prescription.medication_id, notes: @prescription.notes, prescribing_doctor: @prescription.prescribing_doctor, purpose: @prescription.purpose, start_date: @prescription.start_date, stop_date: @prescription.stop_date, time_of_day: @prescription.time_of_day } }
+ end
+
+ assert_redirected_to prescription_url(Prescription.last)
+ end
+
+ test "should show prescription" do
+ get prescription_url(@prescription)
+ assert_response :success
+ end
+
+ test "should get edit" do
+ get edit_prescription_url(@prescription)
+ assert_response :success
+ end
+
+ test "should update prescription" do
+ patch prescription_url(@prescription), params: { prescription: { active: @prescription.active, dosage: @prescription.dosage, frequency: @prescription.frequency, medication_form_id: @prescription.medication_form_id, medication_id: @prescription.medication_id, notes: @prescription.notes, prescribing_doctor: @prescription.prescribing_doctor, purpose: @prescription.purpose, start_date: @prescription.start_date, stop_date: @prescription.stop_date, time_of_day: @prescription.time_of_day } }
+ assert_redirected_to prescription_url(@prescription)
+ end
+
+ test "should destroy prescription" do
+ assert_difference("Prescription.count", -1) do
+ delete prescription_url(@prescription)
+ end
+
+ assert_redirected_to prescriptions_url
+ end
+end
diff --git a/test/fixtures/addresses.yml b/test/fixtures/addresses.yml
new file mode 100644
index 0000000..3a1770c
--- /dev/null
+++ b/test/fixtures/addresses.yml
@@ -0,0 +1,11 @@
+# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
+
+one:
+ city: MyString
+ state: MyString
+ person: one
+
+two:
+ city: MyString
+ state: MyString
+ person: two
diff --git a/test/fixtures/medication_forms.yml b/test/fixtures/medication_forms.yml
new file mode 100644
index 0000000..cd3ef68
--- /dev/null
+++ b/test/fixtures/medication_forms.yml
@@ -0,0 +1,7 @@
+# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
+
+one:
+ name: Capsule
+
+two:
+ name: Liquid
diff --git a/test/fixtures/medication_types.yml b/test/fixtures/medication_types.yml
new file mode 100644
index 0000000..83794d7
--- /dev/null
+++ b/test/fixtures/medication_types.yml
@@ -0,0 +1,7 @@
+# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
+
+one:
+ name: Prescription
+
+two:
+ name: Over-the-counter
diff --git a/test/fixtures/medications.yml b/test/fixtures/medications.yml
new file mode 100644
index 0000000..d5513cc
--- /dev/null
+++ b/test/fixtures/medications.yml
@@ -0,0 +1,18 @@
+# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
+
+one:
+ name: Lisinopril
+ medication_type: one
+ side_effects: Dry cough, dizziness, headache
+
+two:
+ name: Ibuprofen
+ medication_type: two
+ side_effects: Stomach upset, heartburn, dizziness
+
+# No prescription points at this one. A medication that is in use refuses to be
+# destroyed, so the destroy tests need a medication nothing depends on.
+three:
+ name: Warfarin
+ medication_type: one
+ side_effects: Easy bruising, bleeding, nausea
diff --git a/test/fixtures/people.yml b/test/fixtures/people.yml
new file mode 100644
index 0000000..a6957bc
--- /dev/null
+++ b/test/fixtures/people.yml
@@ -0,0 +1,17 @@
+# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
+
+one:
+ first_name: MyString
+ last_name: MyString
+ date_of_birth: 2026-08-28
+ social_security_number: MyString
+ email: MyString
+ relationship: one
+
+two:
+ first_name: MyString
+ last_name: MyString
+ date_of_birth: 2026-08-28
+ social_security_number: MyString
+ email: MyString
+ relationship: two
diff --git a/test/fixtures/prescriptions.yml b/test/fixtures/prescriptions.yml
new file mode 100644
index 0000000..7629f98
--- /dev/null
+++ b/test/fixtures/prescriptions.yml
@@ -0,0 +1,27 @@
+# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
+
+one:
+ medication: one
+ medication_form: one
+ dosage: 10 mg
+ frequency: Once daily
+ time_of_day: Morning
+ prescribing_doctor: Dr. Reyes
+ purpose: Blood pressure
+ active: true
+ start_date: 2026-08-01
+ stop_date: 2026-12-31
+ notes: Take with food
+
+two:
+ medication: two
+ medication_form: two
+ dosage: 200 mg
+ frequency: As needed
+ time_of_day: Evening
+ prescribing_doctor: Dr. Okafor
+ purpose: Joint pain
+ active: false
+ start_date: 2026-07-01
+ stop_date: 2026-07-31
+ notes: Stopped after the flare settled
diff --git a/test/fixtures/relationships.yml b/test/fixtures/relationships.yml
new file mode 100644
index 0000000..7d41224
--- /dev/null
+++ b/test/fixtures/relationships.yml
@@ -0,0 +1,7 @@
+# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
+
+one:
+ name: MyString
+
+two:
+ name: MyString
diff --git a/test/helpers/prescriptions_helper_test.rb b/test/helpers/prescriptions_helper_test.rb
new file mode 100644
index 0000000..adfd173
--- /dev/null
+++ b/test/helpers/prescriptions_helper_test.rb
@@ -0,0 +1,39 @@
+require "test_helper"
+
+class PrescriptionsHelperTest < ActionView::TestCase
+ # The four states the sub-note under the Started column has to cover. The
+ # last one is the one that used to render blank, which read as a broken cell
+ # rather than as a missing stop date.
+ test "still being taken, with no end in sight" do
+ assert_equal "ongoing", prescription_stopped(prescription(active: true, stop_date: nil))
+ end
+
+ test "still being taken, with an end already planned" do
+ assert_equal "until Jan 19, 2026",
+ prescription_stopped(prescription(active: true, stop_date: Date.new(2026, 1, 19)))
+ end
+
+ test "no longer being taken, and we know when it ended" do
+ assert_equal "stopped Jan 19, 2026",
+ prescription_stopped(prescription(active: false, stop_date: Date.new(2026, 1, 19)))
+ end
+
+ test "no longer being taken, and nobody wrote down when" do
+ assert_equal "no stop date recorded",
+ prescription_stopped(prescription(active: false, stop_date: nil))
+ end
+
+ test "a missing start date is dashed rather than left blank" do
+ assert_equal "—", prescription_started(prescription(start_date: nil))
+ end
+
+ test "status says the word, not the boolean" do
+ assert_match "Active", prescription_status(prescription(active: true))
+ assert_match "Stopped", prescription_status(prescription(active: false))
+ end
+
+ private
+ def prescription(**attributes)
+ Prescription.new(attributes.reverse_merge(medication: medications(:one)))
+ end
+end
diff --git a/test/models/medication_form_test.rb b/test/models/medication_form_test.rb
new file mode 100644
index 0000000..63981d1
--- /dev/null
+++ b/test/models/medication_form_test.rb
@@ -0,0 +1,15 @@
+require "test_helper"
+
+class MedicationFormTest < ActiveSupport::TestCase
+ test "requires a name" do
+ assert_not MedicationForm.new.valid?
+ end
+
+ test "leaves its prescriptions behind when destroyed" do
+ prescription = prescriptions(:one)
+
+ prescription.medication_form.destroy
+
+ assert_nil prescription.reload.medication_form_id
+ end
+end
diff --git a/test/models/medication_test.rb b/test/models/medication_test.rb
new file mode 100644
index 0000000..15fb9c4
--- /dev/null
+++ b/test/models/medication_test.rb
@@ -0,0 +1,18 @@
+require "test_helper"
+
+class MedicationTest < ActiveSupport::TestCase
+ test "requires a name" do
+ assert_not Medication.new(side_effects: "Nausea").valid?
+ end
+
+ test "does not require a medication type" do
+ assert Medication.new(name: "Aspirin").valid?
+ end
+
+ test "refuses to be destroyed while a prescription points at it" do
+ medication = medications(:one)
+
+ assert_not medication.destroy
+ assert medication.errors.any?
+ end
+end
diff --git a/test/models/medication_type_test.rb b/test/models/medication_type_test.rb
new file mode 100644
index 0000000..6813a2b
--- /dev/null
+++ b/test/models/medication_type_test.rb
@@ -0,0 +1,15 @@
+require "test_helper"
+
+class MedicationTypeTest < ActiveSupport::TestCase
+ test "requires a name" do
+ assert_not MedicationType.new.valid?
+ end
+
+ test "leaves its medications behind when destroyed" do
+ medication = medications(:one)
+
+ medication.medication_type.destroy
+
+ assert_nil medication.reload.medication_type_id
+ end
+end
diff --git a/test/models/person_test.rb b/test/models/person_test.rb
new file mode 100644
index 0000000..357c9af
--- /dev/null
+++ b/test/models/person_test.rb
@@ -0,0 +1,7 @@
+require "test_helper"
+
+class PersonTest < ActiveSupport::TestCase
+ # test "the truth" do
+ # assert true
+ # end
+end
diff --git a/test/models/prescription_test.rb b/test/models/prescription_test.rb
new file mode 100644
index 0000000..88bfb6e
--- /dev/null
+++ b/test/models/prescription_test.rb
@@ -0,0 +1,15 @@
+require "test_helper"
+
+class PrescriptionTest < ActiveSupport::TestCase
+ test "requires a medication" do
+ assert_not Prescription.new(dosage: "10 mg").valid?
+ end
+
+ test "starts out active" do
+ assert Prescription.new(medication: medications(:one)).active?
+ end
+
+ test "does not require a form" do
+ assert Prescription.new(medication: medications(:one)).valid?
+ end
+end
diff --git a/test/models/relationship_test.rb b/test/models/relationship_test.rb
new file mode 100644
index 0000000..6b1491a
--- /dev/null
+++ b/test/models/relationship_test.rb
@@ -0,0 +1,7 @@
+require "test_helper"
+
+class RelationshipTest < ActiveSupport::TestCase
+ # test "the truth" do
+ # assert true
+ # end
+end
diff --git a/test/models/user_test.rb b/test/models/user_test.rb
new file mode 100644
index 0000000..5c07f49
--- /dev/null
+++ b/test/models/user_test.rb
@@ -0,0 +1,7 @@
+require "test_helper"
+
+class UserTest < ActiveSupport::TestCase
+ # test "the truth" do
+ # assert true
+ # end
+end