diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 0776e9f..a42eb9d 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -233,6 +233,119 @@ code, pre { font-family: var(--font-mono); font-size: 0.9375em; } .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)); @@ -312,6 +425,111 @@ 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 @@ -325,7 +543,8 @@ form:has(input[name="_method"][value="delete"]) button:hover { 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^="medication_"], +div[id^="prescription_"] { padding: var(--space-3) var(--space-4); margin-bottom: var(--space-3); border: 1px solid hsl(var(--border)); @@ -336,7 +555,8 @@ div[id^="medication_"] { /* 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^="medication_"] strong, +div[id^="prescription_"] strong { display: block; color: hsl(var(--muted-foreground)); font-size: 0.8125rem; @@ -346,10 +566,12 @@ div[id^="medication_"] strong { } div[id^="person_"] > div, -div[id^="medication_"] > div { margin-bottom: var(--space-2); } +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 { margin-bottom: 0; } +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 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/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/medications_controller.rb b/app/controllers/medications_controller.rb index 804f765..4c7c4d7 100644 --- a/app/controllers/medications_controller.rb +++ b/app/controllers/medications_controller.rb @@ -49,11 +49,16 @@ def update # DELETE /medications/1 or /medications/1.json def destroy - @medication.destroy! - + # A medication in use by a prescription refuses to be destroyed, so this + # reports the refusal rather than raising. respond_to do |format| - format.html { redirect_to medications_path, notice: "Medication was successfully destroyed.", status: :see_other } - format.json { head :no_content } + 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 @@ -65,6 +70,6 @@ def set_medication # Only allow a list of trusted parameters through. def medication_params - params.expect(medication: [ :name, :medication_type_id, :current, :dosage, :frequency, :time_of_day, :form, :purpose, :start_date, :stop_date, :refill, :notes ]) + params.expect(medication: [ :name, :medication_type_id, :side_effects ]) 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 1bc4396..ec88b20 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -8,9 +8,47 @@ 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/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/medication.rb b/app/models/medication.rb index 3527fc1..71f13dc 100644 --- a/app/models/medication.rb +++ b/app/models/medication.rb @@ -1,3 +1,7 @@ class Medication < ApplicationRecord - belongs_to :medication_type + 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 index 548d639..2ccd64f 100644 --- a/app/models/medication_type.rb +++ b/app/models/medication_type.rb @@ -1,2 +1,5 @@ class MedicationType < ApplicationRecord + has_many :medications, dependent: :nullify + + validates :name, presence: true 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/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. +

+ + diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 591b25b..b6cad65 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -46,10 +46,16 @@ current_page?. %> <%= link_to "People", people_path, aria: { current: aria_current_section(people_path) } %> - <%= link_to "Medications", medications_path, - aria: { current: aria_current_section(medications_path) } %> - <%= link_to "Medication types", medication_types_path, - aria: { current: aria_current_section(medication_types_path) } %> + <%= link_to "Prescriptions", prescriptions_path, + aria: { current: aria_current_section(prescriptions_path) } %> + + <%# One link for the whole administrative area. The lookup tables it + covers - medications, types, forms - are listed on /admin and in + the sub-nav below, which is where they belong: they are the app's + setup, not the work it exists for, and five flat links gave them + equal billing with People and Prescriptions. %> + <%= link_to "Admin", admin_path, + aria: { current: ("page" if admin_area?) } %> <%# Devise signs out via DELETE, so this needs Turbo to issue the verb. button_to would work without it, but it would land in the @@ -69,6 +75,8 @@
+ <%= 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:

+ + +
+ <% 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/medications/_form.html.erb b/app/views/medications/_form.html.erb index 678d8b6..a8597bf 100644 --- a/app/views/medications/_form.html.erb +++ b/app/views/medications/_form.html.erb @@ -17,58 +17,13 @@
- <%= form.label :medication_type_id, style: "display: block" %> - <%= form.text_field :medication_type_id %> + <%= 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 :current, style: "display: block" %> - <%= form.checkbox :current %> -
- -
- <%= 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 :form, style: "display: block" %> - <%= form.text_field :form %> -
- -
- <%= form.label :purpose, style: "display: block" %> - <%= form.textarea :purpose %> -
- -
- <%= 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 :refill, style: "display: block" %> - <%= form.text_field :refill %> -
- -
- <%= form.label :notes, style: "display: block" %> - <%= form.textarea :notes %> + <%= form.label :side_effects, style: "display: block" %> + <%= form.textarea :side_effects %>
diff --git a/app/views/medications/_medication.html.erb b/app/views/medications/_medication.html.erb index 9d4de65..fbe227b 100644 --- a/app/views/medications/_medication.html.erb +++ b/app/views/medications/_medication.html.erb @@ -6,57 +6,12 @@
Medication type: - <%= medication.medication_type_id %> + <%= medication.medication_type&.name %>
- Current: - <%= medication.current %> -
- -
- Dosage: - <%= medication.dosage %> -
- -
- Frequency: - <%= medication.frequency %> -
- -
- Time of day: - <%= medication.time_of_day %> -
- -
- Form: - <%= medication.form %> -
- -
- Purpose: - <%= medication.purpose %> -
- -
- Start date: - <%= medication.start_date %> -
- -
- Stop date: - <%= medication.stop_date %> -
- -
- Refill: - <%= medication.refill %> -
- -
- Notes: - <%= medication.notes %> + Side effects: + <%= medication.side_effects %>
diff --git a/app/views/medications/show.html.erb b/app/views/medications/show.html.erb index 603914f..522d5c7 100644 --- a/app/views/medications/show.html.erb +++ b/app/views/medications/show.html.erb @@ -1,4 +1,5 @@

<%= notice %>

+

<%= alert %>

<%= render @medication %> 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:

+ + +
+ <% 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/routes.rb b/config/routes.rb index bce874b..683683e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,7 +1,9 @@ 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 @@ -29,6 +31,12 @@ # 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 "home#index" get "world" => "home#world" 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 36dd645..84a1e9a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_08_29_145325) 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 @@ -20,6 +20,12 @@ 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" @@ -28,18 +34,9 @@ create_table "medications", force: :cascade do |t| t.datetime "created_at", null: false - t.boolean "current" - t.string "dosage" - t.string "form" - t.string "frequency" - t.integer "medication_type_id", null: false + t.integer "medication_type_id" t.string "name" - t.text "notes" - t.text "purpose" - t.string "refill" - t.date "start_date" - t.date "stop_date" - t.string "time_of_day" + t.text "side_effects" t.datetime "updated_at", null: false t.index ["medication_type_id"], name: "index_medications_on_medication_type_id" end @@ -56,6 +53,24 @@ 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" @@ -98,4 +113,6 @@ 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/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/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/medications_controller_test.rb b/test/controllers/medications_controller_test.rb index 58f7f29..2b8834d 100644 --- a/test/controllers/medications_controller_test.rb +++ b/test/controllers/medications_controller_test.rb @@ -17,12 +17,20 @@ class MedicationsControllerTest < ActionDispatch::IntegrationTest test "should create medication" do assert_difference("Medication.count") do - post medications_url, params: { medication: { current: @medication.current, dosage: @medication.dosage, form: @medication.form, frequency: @medication.frequency, medication_type_id: @medication.medication_type_id, name: @medication.name, notes: @medication.notes, purpose: @medication.purpose, refill: @medication.refill, start_date: @medication.start_date, stop_date: @medication.stop_date, time_of_day: @medication.time_of_day } } + 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 @@ -34,15 +42,25 @@ class MedicationsControllerTest < ActionDispatch::IntegrationTest end test "should update medication" do - patch medication_url(@medication), params: { medication: { current: @medication.current, dosage: @medication.dosage, form: @medication.form, frequency: @medication.frequency, medication_type_id: @medication.medication_type_id, name: @medication.name, notes: @medication.notes, purpose: @medication.purpose, refill: @medication.refill, start_date: @medication.start_date, stop_date: @medication.stop_date, time_of_day: @medication.time_of_day } } + 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(@medication) + 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/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/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 index 7d41224..83794d7 100644 --- a/test/fixtures/medication_types.yml +++ b/test/fixtures/medication_types.yml @@ -1,7 +1,7 @@ # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html one: - name: MyString + name: Prescription two: - name: MyString + name: Over-the-counter diff --git a/test/fixtures/medications.yml b/test/fixtures/medications.yml index 2487777..d5513cc 100644 --- a/test/fixtures/medications.yml +++ b/test/fixtures/medications.yml @@ -1,29 +1,18 @@ # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html one: - name: MyString - medication_type: two - current: false - dosage: MyString - frequency: MyString - time_of_day: MyString - form: MyString - purpose: MyText - start_date: 2026-08-29 - stop_date: 2026-08-29 - refill: MyString - notes: MyText + name: Lisinopril + medication_type: one + side_effects: Dry cough, dizziness, headache two: - name: MyString + name: Ibuprofen medication_type: two - current: false - dosage: MyString - frequency: MyString - time_of_day: MyString - form: MyString - purpose: MyText - start_date: 2026-08-29 - stop_date: 2026-08-29 - refill: MyString - notes: MyText + 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/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/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 index 8fcd105..15fb9c4 100644 --- a/test/models/medication_test.rb +++ b/test/models/medication_test.rb @@ -1,7 +1,18 @@ require "test_helper" class MedicationTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end + 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 index 6417e1a..6813a2b 100644 --- a/test/models/medication_type_test.rb +++ b/test/models/medication_type_test.rb @@ -1,7 +1,15 @@ require "test_helper" class MedicationTypeTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end + 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/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