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.
+
+
+
+ <% 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. %>
+
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:
+ <%= link_to "Show this prescription", @prescription %> |
+ <%= link_to "Back to prescriptions", prescriptions_path %>
+
diff --git a/app/views/prescriptions/index.html.erb b/app/views/prescriptions/index.html.erb
new file mode 100644
index 0000000..32ca691
--- /dev/null
+++ b/app/views/prescriptions/index.html.erb
@@ -0,0 +1,52 @@
+
<%= notice %>
+
+<% content_for :title, "Prescriptions" %>
+
+
Prescriptions
+
+<% if @prescriptions.any? %>
+ <%# The wrapper, not the table, is what scrolls on a narrow screen. tabindex
+ makes that scroll reachable without a mouse, and the role/label keep a
+ focusable div from being announced as an unnamed group. %>
+
+
+
+
+
Medication
+
Dosage
+
Frequency
+
Time of day
+
Prescribing doctor
+
Started
+
Status
+
+
+
+
+ <% @prescriptions.each do |prescription| %>
+
+
+ <%= link_to prescription.medication.name, prescription %>
+ <% if prescription.medication_form %>
+ <%= prescription.medication_form.name %>
+ <% end %>
+
+ <%= link_to "Back to prescriptions", prescriptions_path %>
+
diff --git a/app/views/prescriptions/show.html.erb b/app/views/prescriptions/show.html.erb
new file mode 100644
index 0000000..efa49f9
--- /dev/null
+++ b/app/views/prescriptions/show.html.erb
@@ -0,0 +1,10 @@
+
<%= notice %>
+
+<%= render @prescription %>
+
+
+ <%= link_to "Edit this prescription", edit_prescription_path(@prescription) %> |
+ <%= link_to "Back to prescriptions", prescriptions_path %>
+
+ <%= button_to "Destroy this prescription", @prescription, method: :delete %>
+
diff --git a/app/views/prescriptions/show.json.jbuilder b/app/views/prescriptions/show.json.jbuilder
new file mode 100644
index 0000000..d70e156
--- /dev/null
+++ b/app/views/prescriptions/show.json.jbuilder
@@ -0,0 +1 @@
+json.partial! "prescriptions/prescription", prescription: @prescription
diff --git a/app/views/shared/_admin_nav.html.erb b/app/views/shared/_admin_nav.html.erb
new file mode 100644
index 0000000..7024434
--- /dev/null
+++ b/app/views/shared/_admin_nav.html.erb
@@ -0,0 +1,20 @@
+<%# The second nav band, shown by the layout on every page of the
+ administrative area. It is what makes /medications feel like part of a
+ place rather than another top-level section: the header's Admin link stays
+ lit, and this row says which admin section you are in.
+
+ "Overview" is here rather than as a heading link because it is one of the
+ destinations - from a medication form, /admin is somewhere you go back to. %>
+
diff --git a/config/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