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 @@ +

+
+ City: + <%= address.city %> +
+ +
+ State: + <%= address.state %> +
+ +
diff --git a/app/views/addresses/_address.json.jbuilder b/app/views/addresses/_address.json.jbuilder new file mode 100644 index 0000000..95169b4 --- /dev/null +++ b/app/views/addresses/_address.json.jbuilder @@ -0,0 +1,2 @@ +json.extract! address, :id, :city, :state, :created_at, :updated_at +json.url address_url(address, format: :json) diff --git a/app/views/addresses/index.html.erb b/app/views/addresses/index.html.erb new file mode 100644 index 0000000..a7f798a --- /dev/null +++ b/app/views/addresses/index.html.erb @@ -0,0 +1,16 @@ +

<%= notice %>

+ +<% content_for :title, "Addresses" %> + +

Addresses

+ +
+ <% @addresses.each do |address| %> + <%= render address %> +

+ <%= link_to "Show this address", address %> +

+ <% end %> +
+ +<%= link_to "New address", new_address_path %> diff --git a/app/views/addresses/index.json.jbuilder b/app/views/addresses/index.json.jbuilder new file mode 100644 index 0000000..475be0e --- /dev/null +++ b/app/views/addresses/index.json.jbuilder @@ -0,0 +1 @@ +json.array! @addresses, partial: "addresses/address", as: :address diff --git a/app/views/addresses/show.html.erb b/app/views/addresses/show.html.erb new file mode 100644 index 0000000..dd36790 --- /dev/null +++ b/app/views/addresses/show.html.erb @@ -0,0 +1,9 @@ +

<%= notice %>

+ +<%= render @address %> + +
+ <%= link_to "Back to person", @person %> + + <%= button_to "Destroy this address", person_address_path(@person), method: :delete %> +
diff --git a/app/views/addresses/show.json.jbuilder b/app/views/addresses/show.json.jbuilder new file mode 100644 index 0000000..8d0edad --- /dev/null +++ b/app/views/addresses/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! "addresses/address", address: @address diff --git a/app/views/admin/index.html.erb b/app/views/admin/index.html.erb new file mode 100644 index 0000000..17ac541 --- /dev/null +++ b/app/views/admin/index.html.erb @@ -0,0 +1,23 @@ +<% content_for :title, "Admin" %> + +

Admin

+ +

+ 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. %> +

    + <%= link_to section[:name], section[:path], class: "admin-card__link" %> +

    +

    <%= section[:description] %>

    +
  • + <% end %> +
diff --git a/app/views/devise/confirmations/new.html.erb b/app/views/devise/confirmations/new.html.erb new file mode 100644 index 0000000..3c21122 --- /dev/null +++ b/app/views/devise/confirmations/new.html.erb @@ -0,0 +1,16 @@ +

Resend confirmation instructions

+ +<%= form_for(resource, as: resource_name, url: confirmation_path(resource_name), html: { method: :post }) do |f| %> + <%= render "devise/shared/error_messages", resource: resource %> + +
+

<%= f.label :email %>

+

<%= f.email_field :email, autofocus: true, autocomplete: "email", value: (resource.pending_reconfirmation? ? resource.unconfirmed_email : resource.email) %>

+
+ +
+ <%= f.submit "Resend confirmation instructions" %> +
+<% end %> + +<%= render "devise/shared/links" %> diff --git a/app/views/devise/mailer/confirmation_instructions.html.erb b/app/views/devise/mailer/confirmation_instructions.html.erb new file mode 100644 index 0000000..dc55f64 --- /dev/null +++ b/app/views/devise/mailer/confirmation_instructions.html.erb @@ -0,0 +1,5 @@ +

Welcome <%= @email %>!

+ +

You can confirm your account email through the link below:

+ +

<%= link_to 'Confirm my account', confirmation_url(@resource, confirmation_token: @token) %>

diff --git a/app/views/devise/mailer/email_changed.html.erb b/app/views/devise/mailer/email_changed.html.erb new file mode 100644 index 0000000..32f4ba8 --- /dev/null +++ b/app/views/devise/mailer/email_changed.html.erb @@ -0,0 +1,7 @@ +

Hello <%= @email %>!

+ +<% if @resource.try(:unconfirmed_email?) %> +

We're contacting you to notify you that your email is being changed to <%= @resource.unconfirmed_email %>.

+<% else %> +

We're contacting you to notify you that your email has been changed to <%= @resource.email %>.

+<% end %> diff --git a/app/views/devise/mailer/password_change.html.erb b/app/views/devise/mailer/password_change.html.erb new file mode 100644 index 0000000..b41daf4 --- /dev/null +++ b/app/views/devise/mailer/password_change.html.erb @@ -0,0 +1,3 @@ +

Hello <%= @resource.email %>!

+ +

We're contacting you to notify you that your password has been changed.

diff --git a/app/views/devise/mailer/reset_password_instructions.html.erb b/app/views/devise/mailer/reset_password_instructions.html.erb new file mode 100644 index 0000000..f667dc1 --- /dev/null +++ b/app/views/devise/mailer/reset_password_instructions.html.erb @@ -0,0 +1,8 @@ +

Hello <%= @resource.email %>!

+ +

Someone has requested a link to change your password. You can do this through the link below.

+ +

<%= link_to 'Change my password', edit_password_url(@resource, reset_password_token: @token) %>

+ +

If you didn't request this, please ignore this email.

+

Your password won't change until you access the link above and create a new one.

diff --git a/app/views/devise/mailer/unlock_instructions.html.erb b/app/views/devise/mailer/unlock_instructions.html.erb new file mode 100644 index 0000000..41e148b --- /dev/null +++ b/app/views/devise/mailer/unlock_instructions.html.erb @@ -0,0 +1,7 @@ +

Hello <%= @resource.email %>!

+ +

Your account has been locked due to an excessive number of unsuccessful sign in attempts.

+ +

Click the link below to unlock your account:

+ +

<%= link_to 'Unlock my account', unlock_url(@resource, unlock_token: @token) %>

diff --git a/app/views/devise/passwords/edit.html.erb b/app/views/devise/passwords/edit.html.erb new file mode 100644 index 0000000..3f1dbc7 --- /dev/null +++ b/app/views/devise/passwords/edit.html.erb @@ -0,0 +1,25 @@ +

Change your password

+ +<%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put }) do |f| %> + <%= render "devise/shared/error_messages", resource: resource %> + <%= f.hidden_field :reset_password_token %> + +
+

<%= f.label :password, "New password" %>

+ <% if @minimum_password_length %> +

(<%= @minimum_password_length %> characters minimum)

+ <% end %> +

<%= f.password_field :password, autofocus: true, autocomplete: "new-password" %>

+
+ +
+

<%= f.label :password_confirmation, "Confirm new password" %>

+

<%= f.password_field :password_confirmation, autocomplete: "new-password" %>

+
+ +
+ <%= f.submit "Change my password" %> +
+<% end %> + +<%= render "devise/shared/links" %> diff --git a/app/views/devise/passwords/new.html.erb b/app/views/devise/passwords/new.html.erb new file mode 100644 index 0000000..bea7ad1 --- /dev/null +++ b/app/views/devise/passwords/new.html.erb @@ -0,0 +1,16 @@ +

Forgot your password?

+ +<%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post }) do |f| %> + <%= render "devise/shared/error_messages", resource: resource %> + +
+

<%= f.label :email %>

+

<%= f.email_field :email, autofocus: true, autocomplete: "email" %>

+
+ +
+ <%= f.submit "Send me password reset instructions" %> +
+<% end %> + +<%= render "devise/shared/links" %> diff --git a/app/views/devise/registrations/edit.html.erb b/app/views/devise/registrations/edit.html.erb new file mode 100644 index 0000000..19bb019 --- /dev/null +++ b/app/views/devise/registrations/edit.html.erb @@ -0,0 +1,42 @@ +

Edit <%= resource_name.to_s.humanize %>

+ +<%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put }) do |f| %> + <%= render "devise/shared/error_messages", resource: resource %> + +
+

<%= f.label :email %>

+

<%= f.email_field :email, autofocus: true, autocomplete: "email" %>

+
+ + <% if devise_mapping.confirmable? && resource.pending_reconfirmation? %> +
Currently waiting confirmation for: <%= resource.unconfirmed_email %>
+ <% end %> + +
+

<%= f.label :password %> (leave blank if you don't want to change it)

+

<%= f.password_field :password, autocomplete: "new-password" %>

+ <% if @minimum_password_length %> +

<%= @minimum_password_length %> characters minimum

+ <% end %> +
+ +
+

<%= f.label :password_confirmation %>

+

<%= f.password_field :password_confirmation, autocomplete: "new-password" %>

+
+ +
+

<%= f.label :current_password %> (we need your current password to confirm your changes)

+

<%= f.password_field :current_password, autocomplete: "current-password" %>

+
+ +
+ <%= f.submit "Update" %> +
+<% end %> + +

Cancel my account

+ +
Unhappy? <%= button_to "Cancel my account", registration_path(resource_name), data: { confirm: "Are you sure?", turbo_confirm: "Are you sure?" }, method: :delete %>
+ +<%= link_to "Back", :back %> diff --git a/app/views/devise/registrations/new.html.erb b/app/views/devise/registrations/new.html.erb new file mode 100644 index 0000000..03f48fb --- /dev/null +++ b/app/views/devise/registrations/new.html.erb @@ -0,0 +1,29 @@ +

Sign up

+ +<%= form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f| %> + <%= render "devise/shared/error_messages", resource: resource %> + +
+

<%= f.label :email %>

+

<%= f.email_field :email, autofocus: true, autocomplete: "email" %>

+
+ +
+

<%= f.label :password %>

+ <% if @minimum_password_length %> +

(<%= @minimum_password_length %> characters minimum)

+ <% end %> +

<%= f.password_field :password, autocomplete: "new-password" %>

+
+ +
+

<%= f.label :password_confirmation %>

+

<%= f.password_field :password_confirmation, autocomplete: "new-password" %>

+
+ +
+ <%= f.submit "Sign up" %> +
+<% end %> + +<%= render "devise/shared/links" %> diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb new file mode 100644 index 0000000..6eeb9fc --- /dev/null +++ b/app/views/devise/sessions/new.html.erb @@ -0,0 +1,26 @@ +

Log in

+ +<%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %> +
+

<%= f.label :email %>

+

<%= f.email_field :email, autofocus: true, autocomplete: "email" %>

+
+ +
+

<%= f.label :password %>

+

<%= f.password_field :password, autocomplete: "current-password" %>

+
+ + <% if devise_mapping.rememberable? %> +
+

<%= f.check_box :remember_me %>

+

<%= f.label :remember_me %>

+
+ <% end %> + +
+ <%= f.submit "Log in" %> +
+<% end %> + +<%= render "devise/shared/links" %> diff --git a/app/views/devise/shared/_error_messages.html.erb b/app/views/devise/shared/_error_messages.html.erb new file mode 100644 index 0000000..8c9c171 --- /dev/null +++ b/app/views/devise/shared/_error_messages.html.erb @@ -0,0 +1,15 @@ +<% if resource.errors.any? %> +
+

+ <%= I18n.t("errors.messages.not_saved", + count: resource.errors.count, + resource: resource.class.model_name.human.downcase) + %> +

+
    + <% resource.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+<% end %> diff --git a/app/views/devise/shared/_links.html.erb b/app/views/devise/shared/_links.html.erb new file mode 100644 index 0000000..21cf422 --- /dev/null +++ b/app/views/devise/shared/_links.html.erb @@ -0,0 +1,25 @@ +<%- if controller_name != 'sessions' %> +

<%= link_to "Log in", new_session_path(resource_name) %>

+<% end %> + +<%- if devise_mapping.registerable? && controller_name != 'registrations' %> +

<%= link_to "Sign up", new_registration_path(resource_name) %>

+<% end %> + +<%- if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' %> +

<%= link_to "Forgot your password?", new_password_path(resource_name) %>

+<% end %> + +<%- if devise_mapping.confirmable? && controller_name != 'confirmations' %> +

<%= link_to "Didn't receive confirmation instructions?", new_confirmation_path(resource_name) %>

+<% end %> + +<%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' %> +

<%= link_to "Didn't receive unlock instructions?", new_unlock_path(resource_name) %>

+<% end %> + +<%- if devise_mapping.omniauthable? %> + <%- resource_class.omniauth_providers.each do |provider| %> +

<%= button_to "Sign in with #{OmniAuth::Utils.camelize(provider)}", omniauth_authorize_path(resource_name, provider), data: { turbo: false } %>

+ <% end %> +<% end %> diff --git a/app/views/devise/unlocks/new.html.erb b/app/views/devise/unlocks/new.html.erb new file mode 100644 index 0000000..6b68d72 --- /dev/null +++ b/app/views/devise/unlocks/new.html.erb @@ -0,0 +1,16 @@ +

Resend unlock instructions

+ +<%= form_for(resource, as: resource_name, url: unlock_path(resource_name), html: { method: :post }) do |f| %> + <%= render "devise/shared/error_messages", resource: resource %> + +
+

<%= f.label :email %>

+

<%= f.email_field :email, autofocus: true, autocomplete: "email" %>

+
+ +
+ <%= f.submit "Resend unlock instructions" %> +
+<% end %> + +<%= render "devise/shared/links" %> diff --git a/app/views/home/index.html.erb b/app/views/home/index.html.erb new file mode 100644 index 0000000..3485ac1 --- /dev/null +++ b/app/views/home/index.html.erb @@ -0,0 +1,11 @@ +

heading

+

Hi

+<%= image_tag "horse.jpg", alt: "Horse", class: "horse-image" %> + +<% if user_signed_in? %> +

<%= link_to 'Sign out', destroy_user_session_path, data: { turbo_method: :delete } %>

+<% else %> +

<%= link_to 'Sign in', new_user_session_path %>

+

<%= link_to 'Sign up', new_user_registration_path %>

+<% 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. %> + + + + + <%= render "shared/admin_nav" if admin_area? %> + +
+ <%= yield %> +
+ +
+ +
diff --git a/app/views/medication_forms/_form.html.erb b/app/views/medication_forms/_form.html.erb new file mode 100644 index 0000000..7030d94 --- /dev/null +++ b/app/views/medication_forms/_form.html.erb @@ -0,0 +1,22 @@ +<%= form_with(model: medication_form) do |form| %> + <% if medication_form.errors.any? %> +
+

<%= pluralize(medication_form.errors.count, "error") %> prohibited this medication_form from being saved:

+ +
    + <% medication_form.errors.each do |error| %> +
  • <%= error.full_message %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= form.label :name, style: "display: block" %> + <%= form.text_field :name %> +
+ +
+ <%= form.submit %> +
+<% end %> diff --git a/app/views/medication_forms/_medication_form.html.erb b/app/views/medication_forms/_medication_form.html.erb new file mode 100644 index 0000000..d3709da --- /dev/null +++ b/app/views/medication_forms/_medication_form.html.erb @@ -0,0 +1,7 @@ +
+
+ Name: + <%= medication_form.name %> +
+ +
diff --git a/app/views/medication_forms/_medication_form.json.jbuilder b/app/views/medication_forms/_medication_form.json.jbuilder new file mode 100644 index 0000000..fafc114 --- /dev/null +++ b/app/views/medication_forms/_medication_form.json.jbuilder @@ -0,0 +1,2 @@ +json.extract! medication_form, :id, :name, :created_at, :updated_at +json.url medication_form_url(medication_form, format: :json) diff --git a/app/views/medication_forms/edit.html.erb b/app/views/medication_forms/edit.html.erb new file mode 100644 index 0000000..3619855 --- /dev/null +++ b/app/views/medication_forms/edit.html.erb @@ -0,0 +1,12 @@ +<% content_for :title, "Editing medication form" %> + +

Editing medication form

+ +<%= render "form", medication_form: @medication_form %> + +
+ +
+ <%= link_to "Show this medication form", @medication_form %> | + <%= link_to "Back to medication forms", medication_forms_path %> +
diff --git a/app/views/medication_forms/index.html.erb b/app/views/medication_forms/index.html.erb new file mode 100644 index 0000000..053bf29 --- /dev/null +++ b/app/views/medication_forms/index.html.erb @@ -0,0 +1,16 @@ +

<%= notice %>

+ +<% content_for :title, "Medication forms" %> + +

Medication forms

+ +
+ <% @medication_forms.each do |medication_form| %> + <%= render medication_form %> +

+ <%= link_to "Show this medication form", medication_form %> +

+ <% end %> +
+ +<%= link_to "New medication form", new_medication_form_path %> diff --git a/app/views/medication_forms/index.json.jbuilder b/app/views/medication_forms/index.json.jbuilder new file mode 100644 index 0000000..1b23c5c --- /dev/null +++ b/app/views/medication_forms/index.json.jbuilder @@ -0,0 +1 @@ +json.array! @medication_forms, partial: "medication_forms/medication_form", as: :medication_form diff --git a/app/views/medication_forms/new.html.erb b/app/views/medication_forms/new.html.erb new file mode 100644 index 0000000..91be4d9 --- /dev/null +++ b/app/views/medication_forms/new.html.erb @@ -0,0 +1,11 @@ +<% content_for :title, "New medication form" %> + +

New medication form

+ +<%= render "form", medication_form: @medication_form %> + +
+ +
+ <%= link_to "Back to medication forms", medication_forms_path %> +
diff --git a/app/views/medication_forms/show.html.erb b/app/views/medication_forms/show.html.erb new file mode 100644 index 0000000..0258580 --- /dev/null +++ b/app/views/medication_forms/show.html.erb @@ -0,0 +1,10 @@ +

<%= notice %>

+ +<%= render @medication_form %> + +
+ <%= link_to "Edit this medication form", edit_medication_form_path(@medication_form) %> | + <%= link_to "Back to medication forms", medication_forms_path %> + + <%= button_to "Destroy this medication form", @medication_form, method: :delete %> +
diff --git a/app/views/medication_forms/show.json.jbuilder b/app/views/medication_forms/show.json.jbuilder new file mode 100644 index 0000000..8ecbaa9 --- /dev/null +++ b/app/views/medication_forms/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! "medication_forms/medication_form", medication_form: @medication_form diff --git a/app/views/medication_types/_form.html.erb b/app/views/medication_types/_form.html.erb new file mode 100644 index 0000000..3fd2f7b --- /dev/null +++ b/app/views/medication_types/_form.html.erb @@ -0,0 +1,22 @@ +<%= form_with(model: medication_type) do |form| %> + <% if medication_type.errors.any? %> +
+

<%= pluralize(medication_type.errors.count, "error") %> prohibited this medication_type from being saved:

+ +
    + <% medication_type.errors.each do |error| %> +
  • <%= error.full_message %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= form.label :name, style: "display: block" %> + <%= form.text_field :name %> +
+ +
+ <%= form.submit %> +
+<% end %> diff --git a/app/views/medication_types/_medication_type.html.erb b/app/views/medication_types/_medication_type.html.erb new file mode 100644 index 0000000..6b97951 --- /dev/null +++ b/app/views/medication_types/_medication_type.html.erb @@ -0,0 +1,7 @@ +
+
+ Name: + <%= medication_type.name %> +
+ +
diff --git a/app/views/medication_types/_medication_type.json.jbuilder b/app/views/medication_types/_medication_type.json.jbuilder new file mode 100644 index 0000000..b8273fa --- /dev/null +++ b/app/views/medication_types/_medication_type.json.jbuilder @@ -0,0 +1,2 @@ +json.extract! medication_type, :id, :name, :created_at, :updated_at +json.url medication_type_url(medication_type, format: :json) diff --git a/app/views/medication_types/edit.html.erb b/app/views/medication_types/edit.html.erb new file mode 100644 index 0000000..de94f6c --- /dev/null +++ b/app/views/medication_types/edit.html.erb @@ -0,0 +1,12 @@ +<% content_for :title, "Editing medication type" %> + +

Editing medication type

+ +<%= render "form", medication_type: @medication_type %> + +
+ +
+ <%= link_to "Show this medication type", @medication_type %> | + <%= link_to "Back to medication types", medication_types_path %> +
diff --git a/app/views/medication_types/index.html.erb b/app/views/medication_types/index.html.erb new file mode 100644 index 0000000..ef7c687 --- /dev/null +++ b/app/views/medication_types/index.html.erb @@ -0,0 +1,16 @@ +

<%= notice %>

+ +<% content_for :title, "Medication types" %> + +

Medication types

+ +
+ <% @medication_types.each do |medication_type| %> + <%= render medication_type %> +

+ <%= link_to "Show this medication type", medication_type %> +

+ <% end %> +
+ +<%= link_to "New medication type", new_medication_type_path %> diff --git a/app/views/medication_types/index.json.jbuilder b/app/views/medication_types/index.json.jbuilder new file mode 100644 index 0000000..699339e --- /dev/null +++ b/app/views/medication_types/index.json.jbuilder @@ -0,0 +1 @@ +json.array! @medication_types, partial: "medication_types/medication_type", as: :medication_type diff --git a/app/views/medication_types/new.html.erb b/app/views/medication_types/new.html.erb new file mode 100644 index 0000000..14c0467 --- /dev/null +++ b/app/views/medication_types/new.html.erb @@ -0,0 +1,11 @@ +<% content_for :title, "New medication type" %> + +

New medication type

+ +<%= render "form", medication_type: @medication_type %> + +
+ +
+ <%= link_to "Back to medication types", medication_types_path %> +
diff --git a/app/views/medication_types/show.html.erb b/app/views/medication_types/show.html.erb new file mode 100644 index 0000000..7ec776e --- /dev/null +++ b/app/views/medication_types/show.html.erb @@ -0,0 +1,10 @@ +

<%= notice %>

+ +<%= render @medication_type %> + +
+ <%= link_to "Edit this medication type", edit_medication_type_path(@medication_type) %> | + <%= link_to "Back to medication types", medication_types_path %> + + <%= button_to "Destroy this medication type", @medication_type, method: :delete %> +
diff --git a/app/views/medication_types/show.json.jbuilder b/app/views/medication_types/show.json.jbuilder new file mode 100644 index 0000000..b175477 --- /dev/null +++ b/app/views/medication_types/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! "medication_types/medication_type", medication_type: @medication_type diff --git a/app/views/medications/_form.html.erb b/app/views/medications/_form.html.erb new file mode 100644 index 0000000..a8597bf --- /dev/null +++ b/app/views/medications/_form.html.erb @@ -0,0 +1,32 @@ +<%= form_with(model: medication) do |form| %> + <% if medication.errors.any? %> +
+

<%= pluralize(medication.errors.count, "error") %> prohibited this medication from being saved:

+ +
    + <% medication.errors.each do |error| %> +
  • <%= error.full_message %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= form.label :name, style: "display: block" %> + <%= form.text_field :name %> +
+ +
+ <%= form.label :medication_type_id, "Medication type", style: "display: block" %> + <%= form.collection_select :medication_type_id, MedicationType.order(:name), :id, :name, include_blank: true %> +
+ +
+ <%= form.label :side_effects, style: "display: block" %> + <%= form.textarea :side_effects %> +
+ +
+ <%= form.submit %> +
+<% end %> diff --git a/app/views/medications/_medication.html.erb b/app/views/medications/_medication.html.erb new file mode 100644 index 0000000..fbe227b --- /dev/null +++ b/app/views/medications/_medication.html.erb @@ -0,0 +1,17 @@ +
+
+ Name: + <%= medication.name %> +
+ +
+ Medication type: + <%= medication.medication_type&.name %> +
+ +
+ Side effects: + <%= medication.side_effects %> +
+ +
diff --git a/app/views/medications/_medication.json.jbuilder b/app/views/medications/_medication.json.jbuilder new file mode 100644 index 0000000..7035a68 --- /dev/null +++ b/app/views/medications/_medication.json.jbuilder @@ -0,0 +1,2 @@ +json.extract! medication, :id, :name, :medication_type_id, :current, :dosage, :frequency, :time_of_day, :form, :purpose, :start_date, :stop_date, :refill, :notes, :created_at, :updated_at +json.url medication_url(medication, format: :json) diff --git a/app/views/medications/edit.html.erb b/app/views/medications/edit.html.erb new file mode 100644 index 0000000..501cf0d --- /dev/null +++ b/app/views/medications/edit.html.erb @@ -0,0 +1,12 @@ +<% content_for :title, "Editing medication" %> + +

Editing medication

+ +<%= render "form", medication: @medication %> + +
+ +
+ <%= link_to "Show this medication", @medication %> | + <%= link_to "Back to medications", medications_path %> +
diff --git a/app/views/medications/index.html.erb b/app/views/medications/index.html.erb new file mode 100644 index 0000000..af091f8 --- /dev/null +++ b/app/views/medications/index.html.erb @@ -0,0 +1,16 @@ +

<%= notice %>

+ +<% content_for :title, "Medications" %> + +

Medications

+ +
+ <% @medications.each do |medication| %> + <%= render medication %> +

+ <%= link_to "Show this medication", medication %> +

+ <% end %> +
+ +<%= link_to "New medication", new_medication_path %> diff --git a/app/views/medications/index.json.jbuilder b/app/views/medications/index.json.jbuilder new file mode 100644 index 0000000..fa6c129 --- /dev/null +++ b/app/views/medications/index.json.jbuilder @@ -0,0 +1 @@ +json.array! @medications, partial: "medications/medication", as: :medication diff --git a/app/views/medications/new.html.erb b/app/views/medications/new.html.erb new file mode 100644 index 0000000..64c6d1c --- /dev/null +++ b/app/views/medications/new.html.erb @@ -0,0 +1,11 @@ +<% content_for :title, "New medication" %> + +

New medication

+ +<%= render "form", medication: @medication %> + +
+ +
+ <%= link_to "Back to medications", medications_path %> +
diff --git a/app/views/medications/show.html.erb b/app/views/medications/show.html.erb new file mode 100644 index 0000000..522d5c7 --- /dev/null +++ b/app/views/medications/show.html.erb @@ -0,0 +1,11 @@ +

<%= notice %>

+

<%= alert %>

+ +<%= render @medication %> + +
+ <%= link_to "Edit this medication", edit_medication_path(@medication) %> | + <%= link_to "Back to medications", medications_path %> + + <%= button_to "Destroy this medication", @medication, method: :delete %> +
diff --git a/app/views/medications/show.json.jbuilder b/app/views/medications/show.json.jbuilder new file mode 100644 index 0000000..b2ae681 --- /dev/null +++ b/app/views/medications/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! "medications/medication", medication: @medication diff --git a/app/views/people/_form.html.erb b/app/views/people/_form.html.erb new file mode 100644 index 0000000..0d19245 --- /dev/null +++ b/app/views/people/_form.html.erb @@ -0,0 +1,47 @@ +<%= form_with(model: person) do |form| %> + <% if person.errors.any? %> +
+

<%= pluralize(person.errors.count, "error") %> prohibited this person from being saved:

+ +
    + <% person.errors.each do |error| %> +
  • <%= error.full_message %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= form.label :first_name, style: "display: block" %> + <%= form.text_field :first_name %> +
+ +
+ <%= form.label :last_name, style: "display: block" %> + <%= form.text_field :last_name %> +
+ +
+ <%= form.label :date_of_birth, style: "display: block" %> + <%= form.date_field :date_of_birth %> +
+ +
+ <%= form.label :social_security_number, style: "display: block" %> + <%= form.text_field :social_security_number %> +
+ +
+ <%= form.label :email, style: "display: block" %> + <%= form.text_field :email %> +
+ +
+ <%= form.label :relationship_id, style: "display: block" %> + <%= form.text_field :relationship_id %> +
+ +
+ <%= form.submit %> +
+<% end %> diff --git a/app/views/people/_person.html.erb b/app/views/people/_person.html.erb new file mode 100644 index 0000000..36f544d --- /dev/null +++ b/app/views/people/_person.html.erb @@ -0,0 +1,32 @@ +
+
+ First name: + <%= person.first_name %> +
+ +
+ Last name: + <%= person.last_name %> +
+ +
+ Date of birth: + <%= person.date_of_birth %> +
+ +
+ Social security number: + <%= person.social_security_number %> +
+ +
+ Email: + <%= person.email %> +
+ +
+ Relationship: + <%= person.relationship_id %> +
+ +
diff --git a/app/views/people/_person.json.jbuilder b/app/views/people/_person.json.jbuilder new file mode 100644 index 0000000..95ae139 --- /dev/null +++ b/app/views/people/_person.json.jbuilder @@ -0,0 +1,2 @@ +json.extract! person, :id, :first_name, :last_name, :date_of_birth, :social_security_number, :email, :relationship_id, :created_at, :updated_at +json.url person_url(person, format: :json) diff --git a/app/views/people/edit.html.erb b/app/views/people/edit.html.erb new file mode 100644 index 0000000..82fbae5 --- /dev/null +++ b/app/views/people/edit.html.erb @@ -0,0 +1,12 @@ +<% content_for :title, "Editing person" %> + +

Editing person

+ +<%= render "form", person: @person %> + +
+ +
+ <%= link_to "Show this person", @person %> | + <%= link_to "Back to people", people_path %> +
diff --git a/app/views/people/index.html.erb b/app/views/people/index.html.erb new file mode 100644 index 0000000..c4611fb --- /dev/null +++ b/app/views/people/index.html.erb @@ -0,0 +1,16 @@ +

<%= notice %>

+ +<% content_for :title, "People" %> + +

People

+ +
+ <% @people.each do |person| %> + <%= render person %> +

+ <%= link_to "Show this person", person %> +

+ <% end %> +
+ +<%= link_to "New person", new_person_path %> diff --git a/app/views/people/index.json.jbuilder b/app/views/people/index.json.jbuilder new file mode 100644 index 0000000..14d7899 --- /dev/null +++ b/app/views/people/index.json.jbuilder @@ -0,0 +1 @@ +json.array! @people, partial: "people/person", as: :person diff --git a/app/views/people/new.html.erb b/app/views/people/new.html.erb new file mode 100644 index 0000000..d26f879 --- /dev/null +++ b/app/views/people/new.html.erb @@ -0,0 +1,11 @@ +<% content_for :title, "New person" %> + +

New person

+ +<%= render "form", person: @person %> + +
+ +
+ <%= link_to "Back to people", people_path %> +
diff --git a/app/views/people/show.html.erb b/app/views/people/show.html.erb new file mode 100644 index 0000000..5fa7251 --- /dev/null +++ b/app/views/people/show.html.erb @@ -0,0 +1,10 @@ +

<%= notice %>

+ +<%= render @person %> + +
+ <%= link_to "Edit this person", edit_person_path(@person) %> | + <%= link_to "Back to people", people_path %> + + <%= button_to "Destroy this person", @person, method: :delete %> +
diff --git a/app/views/people/show.json.jbuilder b/app/views/people/show.json.jbuilder new file mode 100644 index 0000000..cf0f817 --- /dev/null +++ b/app/views/people/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! "people/person", person: @person diff --git a/app/views/prescriptions/_form.html.erb b/app/views/prescriptions/_form.html.erb new file mode 100644 index 0000000..742a49d --- /dev/null +++ b/app/views/prescriptions/_form.html.erb @@ -0,0 +1,72 @@ +<%= form_with(model: prescription) do |form| %> + <% if prescription.errors.any? %> +
+

<%= pluralize(prescription.errors.count, "error") %> prohibited this prescription from being saved:

+ +
    + <% prescription.errors.each do |error| %> +
  • <%= error.full_message %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= form.label :medication_id, "Medication", style: "display: block" %> + <%= form.collection_select :medication_id, Medication.order(:name), :id, :name, include_blank: true %> +
+ +
+ <%= form.label :medication_form_id, "Form", style: "display: block" %> + <%= form.collection_select :medication_form_id, MedicationForm.order(:name), :id, :name, include_blank: true %> +
+ +
+ <%= form.label :dosage, style: "display: block" %> + <%= form.text_field :dosage %> +
+ +
+ <%= form.label :frequency, style: "display: block" %> + <%= form.text_field :frequency %> +
+ +
+ <%= form.label :time_of_day, style: "display: block" %> + <%= form.text_field :time_of_day %> +
+ +
+ <%= form.label :prescribing_doctor, style: "display: block" %> + <%= form.text_field :prescribing_doctor %> +
+ +
+ <%= form.label :purpose, style: "display: block" %> + <%= form.textarea :purpose %> +
+ +
+ <%= form.label :active, style: "display: block" %> + <%= form.checkbox :active %> +
+ +
+ <%= form.label :start_date, style: "display: block" %> + <%= form.date_field :start_date %> +
+ +
+ <%= form.label :stop_date, style: "display: block" %> + <%= form.date_field :stop_date %> +
+ +
+ <%= form.label :notes, style: "display: block" %> + <%= form.textarea :notes %> +
+ +
+ <%= form.submit %> +
+<% end %> diff --git a/app/views/prescriptions/_prescription.html.erb b/app/views/prescriptions/_prescription.html.erb new file mode 100644 index 0000000..e130fa1 --- /dev/null +++ b/app/views/prescriptions/_prescription.html.erb @@ -0,0 +1,57 @@ +
+
+ Medication: + <%= prescription.medication.name %> +
+ +
+ Form: + <%= prescription.medication_form&.name %> +
+ +
+ Dosage: + <%= prescription.dosage %> +
+ +
+ Frequency: + <%= prescription.frequency %> +
+ +
+ Time of day: + <%= prescription.time_of_day %> +
+ +
+ Prescribing doctor: + <%= prescription.prescribing_doctor %> +
+ +
+ Purpose: + <%= prescription.purpose %> +
+ +
+ Active: + <%= prescription_status prescription %> +
+ +
+ Start date: + <%= or_dash prescription_date(prescription.start_date) %> +
+ +
+ Stop date: + <%= or_dash prescription_date(prescription.stop_date) %> +
+ +
+ Notes: + <%= prescription.notes %> +
+ +
diff --git a/app/views/prescriptions/_prescription.json.jbuilder b/app/views/prescriptions/_prescription.json.jbuilder new file mode 100644 index 0000000..ac80f17 --- /dev/null +++ b/app/views/prescriptions/_prescription.json.jbuilder @@ -0,0 +1,2 @@ +json.extract! prescription, :id, :medication_id, :medication_form_id, :dosage, :frequency, :time_of_day, :prescribing_doctor, :purpose, :active, :start_date, :stop_date, :notes, :created_at, :updated_at +json.url prescription_url(prescription, format: :json) diff --git a/app/views/prescriptions/edit.html.erb b/app/views/prescriptions/edit.html.erb new file mode 100644 index 0000000..06c9f86 --- /dev/null +++ b/app/views/prescriptions/edit.html.erb @@ -0,0 +1,12 @@ +<% content_for :title, "Editing prescription" %> + +

Editing prescription

+ +<%= render "form", prescription: @prescription %> + +
+ +
+ <%= 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. %> +
+ + + + + + + + + + + + + + + <% @prescriptions.each do |prescription| %> + + + + + + + + + + <% end %> + +
MedicationDosageFrequencyTime of dayPrescribing doctorStartedStatus
+ <%= link_to prescription.medication.name, prescription %> + <% if prescription.medication_form %> + <%= prescription.medication_form.name %> + <% end %> + <%= or_dash prescription.dosage %><%= or_dash prescription.frequency %><%= or_dash prescription.time_of_day %><%= or_dash prescription.prescribing_doctor %> + <%= prescription_started prescription %> + <%= prescription_stopped prescription %> + <%= prescription_status prescription %>
+
+<% else %> +

No prescriptions yet.

+<% end %> + +<%= link_to "New prescription", new_prescription_path %> diff --git a/app/views/prescriptions/index.json.jbuilder b/app/views/prescriptions/index.json.jbuilder new file mode 100644 index 0000000..8751726 --- /dev/null +++ b/app/views/prescriptions/index.json.jbuilder @@ -0,0 +1 @@ +json.array! @prescriptions, partial: "prescriptions/prescription", as: :prescription diff --git a/app/views/prescriptions/new.html.erb b/app/views/prescriptions/new.html.erb new file mode 100644 index 0000000..e0b3c07 --- /dev/null +++ b/app/views/prescriptions/new.html.erb @@ -0,0 +1,11 @@ +<% content_for :title, "New prescription" %> + +

New prescription

+ +<%= render "form", prescription: @prescription %> + +
+ +
+ <%= 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