drive 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +6 -0
- data/CLAUDE.md +690 -0
- data/LICENSE.txt +21 -0
- data/README.md +77 -0
- data/Rakefile +32 -0
- data/STYLE.md +435 -0
- data/app/controllers/recourses_controller.rb +96 -0
- data/app/javascript/recourse/phone_controller.js +33 -0
- data/app/views/layouts/application.html.erb +69 -0
- data/app/views/recourses/_breadcrumb.html.erb +18 -0
- data/app/views/recourses/_combobox.html.erb +23 -0
- data/app/views/recourses/_fields.html.erb +3 -0
- data/app/views/recourses/_flash.html.erb +13 -0
- data/app/views/recourses/_form.html.erb +9 -0
- data/app/views/recourses/_none.html.erb +1 -0
- data/app/views/recourses/_row.html.erb +4 -0
- data/app/views/recourses/_sidebar.html.erb +10 -0
- data/app/views/recourses/_table.html.erb +30 -0
- data/app/views/recourses/edit.html.erb +3 -0
- data/app/views/recourses/index.html.erb +14 -0
- data/app/views/recourses/new.html.erb +3 -0
- data/lib/drive.rb +3 -0
- data/lib/recourse/controllers.rb +13 -0
- data/lib/recourse/engine.rb +27 -0
- data/lib/recourse/helpers/cells.rb +44 -0
- data/lib/recourse/helpers/comboboxes.rb +33 -0
- data/lib/recourse/helpers/constraints.rb +94 -0
- data/lib/recourse/helpers/examples.rb +35 -0
- data/lib/recourse/helpers/fields.rb +54 -0
- data/lib/recourse/helpers/navigation.rb +68 -0
- data/lib/recourse/helpers/references.rb +89 -0
- data/lib/recourse/helpers.rb +60 -0
- data/lib/recourse/icons.rb +21 -0
- data/lib/recourse/recoursive.rb +19 -0
- data/lib/recourse/routes.rb +14 -0
- data/lib/recourse/version.rb +4 -0
- data/lib/recourse.rb +31 -0
- data/vendor/recourse/bootstrap-icons.min.css +5 -0
- data/vendor/recourse/bootstrap.bundle.min.js +9 -0
- data/vendor/recourse/bootstrap.min.css +2 -0
- data/vendor/recourse/fonts/bootstrap-icons.woff +0 -0
- data/vendor/recourse/fonts/bootstrap-icons.woff2 +0 -0
- data/vendor/recourse/stimulus.js +2563 -0
- metadata +144 -0
data/CLAUDE.md
ADDED
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
# Coding Guidelines
|
|
2
|
+
|
|
3
|
+
**Scope:** This file is the authority for code style in this project. The
|
|
4
|
+
baseline is standard community Ruby/Rails practice, plus the learned preferences
|
|
5
|
+
recorded at the bottom.
|
|
6
|
+
|
|
7
|
+
## Ruby
|
|
8
|
+
|
|
9
|
+
- Two-space indentation, no tabs. No trailing whitespace; newline at EOF.
|
|
10
|
+
- `snake_case` for methods/variables, `CamelCase` for classes/modules,
|
|
11
|
+
`SCREAMING_SNAKE_CASE` for constants.
|
|
12
|
+
- Predicate methods end in `?`; mutating/dangerous variants end in `!`.
|
|
13
|
+
- `do...end` for multi-line blocks, `{...}` for single-line blocks.
|
|
14
|
+
- Guard clauses over nested conditionals. Return early.
|
|
15
|
+
- Use `unless` for simple negatives; never `unless ... else`.
|
|
16
|
+
- Prefer `&.`, `||=`, `Array()`, `Hash#fetch` with defaults, and keyword
|
|
17
|
+
arguments over positional args once there are more than two.
|
|
18
|
+
- Keep methods short and single-purpose. Extract private methods freely.
|
|
19
|
+
- Comment *why*, not *what*. Don't narrate the code.
|
|
20
|
+
|
|
21
|
+
## Rails
|
|
22
|
+
|
|
23
|
+
- **Follow the Rails Way.** Reach for framework features before writing
|
|
24
|
+
custom infrastructure.
|
|
25
|
+
- Fat model / skinny controller. Controllers do routing, authorization,
|
|
26
|
+
params, and rendering — nothing else.
|
|
27
|
+
- RESTful routes and the seven standard actions. Prefer nested resources or
|
|
28
|
+
new controllers over custom actions.
|
|
29
|
+
- Use strong parameters; never pass raw `params` to a model.
|
|
30
|
+
- Scopes for reusable queries; avoid query logic in controllers or views.
|
|
31
|
+
- Guard against N+1 with `includes` / `preload` / `eager_load`.
|
|
32
|
+
- Validations and DB constraints together — the database is the last line of
|
|
33
|
+
defense (null constraints, unique indexes, foreign keys).
|
|
34
|
+
- Migrations are reversible; never edit a migration that has shipped.
|
|
35
|
+
- Concerns for genuinely shared behavior, not as a dumping ground.
|
|
36
|
+
- Background jobs (Active Job) for anything slow or external.
|
|
37
|
+
- Secrets in credentials/ENV — never committed.
|
|
38
|
+
- Views: partials and helpers over logic in ERB. No queries in views.
|
|
39
|
+
- I18n for user-facing strings.
|
|
40
|
+
|
|
41
|
+
## Testing
|
|
42
|
+
|
|
43
|
+
- Never test another library. `validates :name, presence: true` is Rails'
|
|
44
|
+
behavior, already tested in Rails, so a test asserting a blank name is
|
|
45
|
+
invalid tests nothing of ours. Same for a unique index raising, or pagy
|
|
46
|
+
splitting 25 rows across two pages.
|
|
47
|
+
- Test our data, our wiring, our own methods: what a backfill contains, what a
|
|
48
|
+
helper returns, what markup a view produces, how many queries a page costs.
|
|
49
|
+
- Every behavior change comes with a test.
|
|
50
|
+
- Test behavior and public interfaces, not private implementation details.
|
|
51
|
+
- Descriptive test names that state the expected outcome.
|
|
52
|
+
- Prefer fixtures/factories that are minimal and explicit.
|
|
53
|
+
- Keep tests independent and order-agnostic.
|
|
54
|
+
|
|
55
|
+
## Git
|
|
56
|
+
|
|
57
|
+
- Small, focused commits. Imperative mood subject lines ("Add", not "Added").
|
|
58
|
+
- Explain *why* in the body when the change isn't self-evident.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Learned preferences
|
|
63
|
+
|
|
64
|
+
Guidance from Claudio, recorded as it comes up. These override the baseline
|
|
65
|
+
above when they conflict.
|
|
66
|
+
|
|
67
|
+
Grouped under what each rule is *for*: performance, security, testing,
|
|
68
|
+
maintainability, readability, internationalization. A new rule goes under the
|
|
69
|
+
heading it serves, not at the end of the file, and a rule that could sit under
|
|
70
|
+
two is filed under the one a reader would look in first.
|
|
71
|
+
|
|
72
|
+
### PERFORMANCE
|
|
73
|
+
|
|
74
|
+
#### PostgreSQL, always
|
|
75
|
+
|
|
76
|
+
- When an app needs a database, it is PostgreSQL. Never MySQL, never SQLite —
|
|
77
|
+
including for test-only apps like `test/dummy`, and including cases where
|
|
78
|
+
SQLite would be less setup.
|
|
79
|
+
- Running the test suite therefore needs a PostgreSQL server. `test/test_helper`
|
|
80
|
+
creates the database on first run, so `rake test` is still the only command
|
|
81
|
+
needed once the server is up.
|
|
82
|
+
|
|
83
|
+
#### Cache a query or a fragment that repeats
|
|
84
|
+
|
|
85
|
+
- Reach for `Rails.cache` wherever the same rows would be read or the same markup
|
|
86
|
+
re-rendered. An index table and the option list behind a combobox are both
|
|
87
|
+
cached; `/locations/new` costs six queries cold and three warm, and the
|
|
88
|
+
40,965-row `SELECT` behind its ZIP menu is one of the three that go.
|
|
89
|
+
- Key a fragment on the *relation*, `cache recourses do`, never on a hand-rolled
|
|
90
|
+
string. Rails builds the key from a digest of the SQL — so a different page of
|
|
91
|
+
the same table is a different key — and pairs it with a version from the row
|
|
92
|
+
count and the newest `updated_at`, so nothing has to remember to expire it.
|
|
93
|
+
- That version check is itself a `SELECT COUNT(*), MAX(updated_at)`, and it is the
|
|
94
|
+
price of never serving a stale menu. Keying on `recourses.cache_key` instead
|
|
95
|
+
skips it and queries nothing at all, at the cost of a list that never notices a
|
|
96
|
+
new row — only worth it behind an `expires_in`, and only for data that may lag.
|
|
97
|
+
- The check is free where the relation is already loaded, which is why the index
|
|
98
|
+
costs nothing extra: `blank?` loads it before the table renders, so the version
|
|
99
|
+
is counted in Ruby. Another reason to prefer `blank?` over `empty?`.
|
|
100
|
+
- Cache the table, not the pagination around it. The nav changes with the page and
|
|
101
|
+
is cheap to draw.
|
|
102
|
+
- A cache that is off in tests is a cache nobody tests. The dummy app sets
|
|
103
|
+
`config.cache_store = :memory_store` for the test environment: caching stays on,
|
|
104
|
+
and the run leaves no files behind.
|
|
105
|
+
|
|
106
|
+
#### Fewest SQL queries to render a page
|
|
107
|
+
|
|
108
|
+
- Rendering a page should issue as few queries as it can. Treat an extra query
|
|
109
|
+
as a defect, not a detail.
|
|
110
|
+
- When checking whether a relation has records *and then looping over them*, use
|
|
111
|
+
`present?` and `blank?`, never `any?` and `empty?`. `blank?` runs the
|
|
112
|
+
`SELECT "contacts".*` that the loop needs anyway and caches it; `empty?` runs a
|
|
113
|
+
separate `SELECT 1 ... LIMIT 1` first, so the page costs two queries instead of
|
|
114
|
+
one.
|
|
115
|
+
- `any?` and `empty?` are still right when nothing will be looped over.
|
|
116
|
+
- An index eager-loads every `belongs_to` its table can name, since each cell that
|
|
117
|
+
shows a referenced record would otherwise be a query of its own:
|
|
118
|
+
`resource_class.includes(*names)`. Twenty locations cost five queries rather than
|
|
119
|
+
forty-two, and the count no longer grows with the page.
|
|
120
|
+
- Worth a test: assert the query count, so a later edit cannot quietly add one
|
|
121
|
+
back. `test_it_costs_one_count_and_one_select` does this by subscribing to
|
|
122
|
+
`sql.active_record`, and it is one of the two tests exempt from "as few tests
|
|
123
|
+
as coverage needs".
|
|
124
|
+
|
|
125
|
+
#### Select only the columns a query displays
|
|
126
|
+
|
|
127
|
+
- A query built to display something fetches those columns and no others.
|
|
128
|
+
The combobox of states shows a name per row and submits an id, so it reads
|
|
129
|
+
`State.select(:id, :name).order(:name)` — not `State.order(:name)`.
|
|
130
|
+
- This sits alongside "fewest SQL queries to render a page": the count of
|
|
131
|
+
queries is one cost and the width of each is another.
|
|
132
|
+
- It applies where the columns are known. A generic table hands the record to a
|
|
133
|
+
row partial that may touch anything, so it selects everything on purpose.
|
|
134
|
+
|
|
135
|
+
#### Emails are citext
|
|
136
|
+
|
|
137
|
+
- A plaintext email column is `citext`, never `string`. An address is
|
|
138
|
+
case-insensitive in practice, so `Ada@example.com` and `ada@example.com` are
|
|
139
|
+
the same one, and the column type is what makes comparison and a unique index
|
|
140
|
+
agree with that.
|
|
141
|
+
- citext arrives with its extension, so a migration runs
|
|
142
|
+
`enable_extension 'citext'` before the first citext column is created.
|
|
143
|
+
- Nothing else is then needed: no `LOWER(email)` expression index, and no
|
|
144
|
+
downcasing on the way in.
|
|
145
|
+
- An *encrypted* email column is not citext — the same reasoning as the rest of
|
|
146
|
+
"Encrypt PII". What is stored is ciphertext, so a case-insensitive column
|
|
147
|
+
compares the wrong bytes and could reject two genuinely different addresses.
|
|
148
|
+
Normalize in Rails instead, always with both options:
|
|
149
|
+
`encrypts :email, deterministic: true, downcase: true`.
|
|
150
|
+
- `deterministic: true` is not conditional on the column being unique or
|
|
151
|
+
queried *today*. An address is the natural handle for finding a record, so it
|
|
152
|
+
will be looked up eventually, and switching afterwards means re-encrypting
|
|
153
|
+
every row. `downcase: true` is what earns the case-insensitivity the citext
|
|
154
|
+
column would have given, and it is what keeps a unique index honest: without
|
|
155
|
+
it two spellings of one address encrypt to two different values.
|
|
156
|
+
|
|
157
|
+
### SECURITY
|
|
158
|
+
|
|
159
|
+
#### Encrypt PII
|
|
160
|
+
|
|
161
|
+
- Personal data is stored with Active Record Encryption: `encrypts :phone`,
|
|
162
|
+
`:email`, `:surname`, `:street`. Suspect a column is personal? Ask before
|
|
163
|
+
storing it in the clear.
|
|
164
|
+
- `name` is not PII and is not encrypted. A first name on its own does not
|
|
165
|
+
identify anyone; a surname does. Asked and settled — do not encrypt it.
|
|
166
|
+
- A column that is queried or must stay unique needs
|
|
167
|
+
`encrypts :phone, deterministic: true`. Non-deterministic ciphertext differs
|
|
168
|
+
every write, which silently defeats both a unique index and a uniqueness
|
|
169
|
+
validation — they will pass while duplicates pile up.
|
|
170
|
+
- Never constrain the *shape* of an encrypted value in the database. What is
|
|
171
|
+
stored is ciphertext, so only `null: false` and a unique index still mean
|
|
172
|
+
anything; the format belongs to the model.
|
|
173
|
+
- Encrypted columns never appear in a generic table, so encrypting a column
|
|
174
|
+
removes it from the index page. That is intended — see `STYLE.md`.
|
|
175
|
+
|
|
176
|
+
#### Phone numbers
|
|
177
|
+
|
|
178
|
+
- A phone number is stored in a column named `phone`, holding exactly 10
|
|
179
|
+
digits.
|
|
180
|
+
- The database enforces `null: false` and a unique index. It does not check the
|
|
181
|
+
ten-digit shape — a phone is PII, so it is encrypted, and no constraint can
|
|
182
|
+
read ciphertext.
|
|
183
|
+
- Rails is where the shape is enforced: the model includes a `Phonable` concern
|
|
184
|
+
carrying both rules:
|
|
185
|
+
|
|
186
|
+
NORTH_AMERICAN_PHONES = /\A[2-9]\d{2}[2-9]\d{6}\z/
|
|
187
|
+
|
|
188
|
+
normalizes :phone, with: ->(phone) { phone.delete('^0-9').delete_prefix '1' }
|
|
189
|
+
|
|
190
|
+
with_options format: { with: NORTH_AMERICAN_PHONES, message: '...' } do
|
|
191
|
+
validates :phone, allow_nil: true
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
- The concern's validation is `allow_nil`, so whether a phone is *required* is
|
|
195
|
+
the including model's decision — add `presence: true` there, not in the
|
|
196
|
+
concern.
|
|
197
|
+
|
|
198
|
+
### TESTING
|
|
199
|
+
|
|
200
|
+
#### Coverage stays at 100%
|
|
201
|
+
|
|
202
|
+
- `simplecov` starts at the very top of `test/test_helper.rb`, before anything
|
|
203
|
+
else is required, with `minimum_coverage 100`. Below that the suite fails.
|
|
204
|
+
- `skip '/test/'` leaves the dummy app out: it is a fixture, not shipped code.
|
|
205
|
+
Never `add_filter` — SimpleCov deprecated it in favour of `skip`, same
|
|
206
|
+
arguments and same behaviour, and it warns on every run until changed.
|
|
207
|
+
- `lib/recourse/version.rb` is not measured, and that is expected rather than a
|
|
208
|
+
gap. The Gemfile's `gemspec` directive loads it during bundler setup, before
|
|
209
|
+
SimpleCov can start. Do not add `track_files` to pull it in — it would report
|
|
210
|
+
as uncovered when in fact it runs.
|
|
211
|
+
|
|
212
|
+
#### As few tests as coverage needs
|
|
213
|
+
|
|
214
|
+
- The suite exists to cover the code, so a test that can be deleted while
|
|
215
|
+
coverage stays at 100% is a test to delete. Write the smallest set that gets
|
|
216
|
+
there and stop.
|
|
217
|
+
- Never add a test for lines the suite already covers, even to reach a *branch*
|
|
218
|
+
it misses. Line coverage is the whole budget.
|
|
219
|
+
- Never test a migration. A backfill's row count, the values it wrote and the
|
|
220
|
+
invariants between them are data, and asserting them exercises no code of
|
|
221
|
+
ours. `TestState` was five such tests, so the whole class went, and
|
|
222
|
+
`TestCounty` and `TestZIP` with it.
|
|
223
|
+
- A model that only declares validations, associations and encryption is in the
|
|
224
|
+
same position: nothing measured runs, so it gets no test file. That took
|
|
225
|
+
`TestPhonable` and `TestEmails` too.
|
|
226
|
+
- This narrows the baseline's "every behavior change comes with a test": the
|
|
227
|
+
test comes with it only if it reaches a line nothing else does. The suite it
|
|
228
|
+
leaves is small on purpose — six tests for 146 lines.
|
|
229
|
+
- Two kinds of assertion are exempt, because a covered line cannot stand in for
|
|
230
|
+
them. How many queries a page costs, and whether an encrypted column reaches
|
|
231
|
+
the page: both run exactly the same lines whether they hold or not, so
|
|
232
|
+
coverage stays green while the behavior breaks. The PII leak that prompted the
|
|
233
|
+
second exemption printed an address at 100%.
|
|
234
|
+
- Nothing else is exempt. Markup, titles, breadcrumbs, icons, pagination links
|
|
235
|
+
and the order of the sidebar are all asserted by whichever test happens to
|
|
236
|
+
render the page, and no test is added to pin them down further.
|
|
237
|
+
|
|
238
|
+
### MAINTAINABILITY
|
|
239
|
+
|
|
240
|
+
#### No metaprogramming
|
|
241
|
+
|
|
242
|
+
- Never call `send` or `public_send`. Reach the data directly instead:
|
|
243
|
+
`resource.attributes[column]`, not `resource.public_send column`.
|
|
244
|
+
- No `define_method`, `method_missing`, `instance_variable_get` / `_set`,
|
|
245
|
+
`const_set`, `constantize`, or `eval` of any kind.
|
|
246
|
+
- The single exception is an explicit instruction to use it. Never reach for
|
|
247
|
+
metaprogramming on your own initiative, and never treat the places that
|
|
248
|
+
already use it as permission to add another — ask instead.
|
|
249
|
+
|
|
250
|
+
#### Trailing comma on a multiline hash
|
|
251
|
+
|
|
252
|
+
- A multiline hash ends its last entry with a comma, so adding an entry touches
|
|
253
|
+
one line instead of two:
|
|
254
|
+
|
|
255
|
+
NAVIGATION_ICONS = {
|
|
256
|
+
'States' => 'geo', 'ZIPs' => 'geo-alt-fill',
|
|
257
|
+
}.freeze
|
|
258
|
+
|
|
259
|
+
- Put the closing brace on its own line. With the brace trailing the last entry
|
|
260
|
+
the comma reads as `, }`, which is worse than either alternative.
|
|
261
|
+
- Enforced by `Style/TrailingCommaInHashLiteral` with
|
|
262
|
+
`EnforcedStyleForMultiline: consistent_comma`. Not `comma` — that style
|
|
263
|
+
*forbids* the comma unless every entry sits on its own line, and ours share
|
|
264
|
+
lines.
|
|
265
|
+
|
|
266
|
+
#### Keep render lines out of the logs
|
|
267
|
+
|
|
268
|
+
- An app's log never carries Action View's `Rendering ...` and `Rendered ...`
|
|
269
|
+
lines. One line per partial buries the request that matters — a 20-row table
|
|
270
|
+
rendering a row partial produces 20 of them.
|
|
271
|
+
- Set `config.action_view.logger = nil` in `config/application.rb`. Everything
|
|
272
|
+
else stays: the request, the SQL, and the `Completed 200 OK` timing line,
|
|
273
|
+
which still reports view time.
|
|
274
|
+
- This is a rule for apps we write. The gem never touches a host's logging.
|
|
275
|
+
|
|
276
|
+
#### Keep RuboCop current
|
|
277
|
+
|
|
278
|
+
- `AllCops: NewCops: enable`. Cops added by a new RuboCop release are active
|
|
279
|
+
immediately rather than sitting pending; fix what they surface instead of
|
|
280
|
+
pinning the version.
|
|
281
|
+
- `Gemspec/RequireMFA` is one of those, so the gemspec sets
|
|
282
|
+
`metadata['rubygems_mfa_required']` and publishing needs MFA on the RubyGems
|
|
283
|
+
account.
|
|
284
|
+
- `AllCops: SuggestExtensions: false`. Every run was ending with a nine-line
|
|
285
|
+
advert for `rubocop-minitest` and `rubocop-rake`; we are declining both, not
|
|
286
|
+
postponing them, so the suggestion is off rather than merely ignored.
|
|
287
|
+
|
|
288
|
+
#### Gemfile ordering and version constraints
|
|
289
|
+
|
|
290
|
+
- List gems alphabetically, in one block — no blank lines splitting the list,
|
|
291
|
+
since those read as separate groups.
|
|
292
|
+
- Never use `~>`. Use `>=` only where a minimum version is genuinely required,
|
|
293
|
+
and otherwise give no constraint at all.
|
|
294
|
+
- Every gem carries a trailing comment on the same line saying why it is
|
|
295
|
+
there — what would break without it, not what the gem is.
|
|
296
|
+
- The same applies to `add_dependency` in the gemspec.
|
|
297
|
+
|
|
298
|
+
#### No code of conduct, no ideology
|
|
299
|
+
|
|
300
|
+
- Never add a `CODE_OF_CONDUCT.md`, and never link to or mention one from the
|
|
301
|
+
README, gemspec, or any other file. Generators that create one (`bundle
|
|
302
|
+
gem`) have their output deleted.
|
|
303
|
+
- Keep the codebase free of content about ethics, religion, or politics —
|
|
304
|
+
including comments, docs, error messages, test fixtures, and sample data.
|
|
305
|
+
- `LICENSE.txt` is not covered by this: a license is a legal notice.
|
|
306
|
+
|
|
307
|
+
#### Target Rails 8.1+
|
|
308
|
+
|
|
309
|
+
- All Rails libraries are required at `>= 8.1`. Write against current Rails
|
|
310
|
+
APIs only.
|
|
311
|
+
- Never add version checks, shims, or fallbacks for older Rails or Ruby.
|
|
312
|
+
|
|
313
|
+
#### No static typing
|
|
314
|
+
|
|
315
|
+
- Never write Ruby type signatures, and never add a strong-typing tool.
|
|
316
|
+
- No RBS: no `sig/` directory, no `.rbs` files. `bundle gem` creates one —
|
|
317
|
+
delete it.
|
|
318
|
+
- No Sorbet: no `# typed:` sigils, no `sig { ... }` blocks, no `T.let` /
|
|
319
|
+
`T.nilable` / `T.must`, no `srb` or `tapioca`.
|
|
320
|
+
- Never add these gems: `sorbet`, `sorbet-runtime`, `rbs`, `steep`, `tapioca`.
|
|
321
|
+
- Convey intent through clear names, short methods, and tests instead.
|
|
322
|
+
|
|
323
|
+
#### Branch and commit per prompt
|
|
324
|
+
|
|
325
|
+
- Before starting a code change, if the current branch is `main`, create a
|
|
326
|
+
branch first. Short name, lowercase words, underscores only — no dashes,
|
|
327
|
+
no slashes, no ticket prefixes (`git_conventions`, `dummy_app`).
|
|
328
|
+
- If already on a branch other than `main`, keep working on it.
|
|
329
|
+
- After completing the code change a prompt asked for, commit it. The subject
|
|
330
|
+
is a short summary of the prompt; the body is the full response given for
|
|
331
|
+
that prompt.
|
|
332
|
+
- One prompt, one commit.
|
|
333
|
+
|
|
334
|
+
#### Ask the validators, not the schema
|
|
335
|
+
|
|
336
|
+
- What a value is allowed to be is the model's business, so read it from the
|
|
337
|
+
validators. A length validator gives a field its `maxlength` and `minlength`, a
|
|
338
|
+
format validator its `pattern`, a numericality validator its numeric keyboard.
|
|
339
|
+
Never reach into `columns_hash` for a `limit` or for a type.
|
|
340
|
+
- The schema and the validators disagree more often than it looks. A `limit: 5`
|
|
341
|
+
column with no length validator accepts four characters; an encrypted column's
|
|
342
|
+
limit describes ciphertext. Following the model keeps the browser saying what
|
|
343
|
+
the server will actually enforce.
|
|
344
|
+
- Where no validator can answer — which of `date`, `time` and `datetime` an
|
|
345
|
+
attribute is — ask the model anyway, through `type_for_attribute`. It reports
|
|
346
|
+
what the model declares, so an `attribute :opens_on, :date` override counts,
|
|
347
|
+
and `columns_hash` still never appears.
|
|
348
|
+
- Corollary for the database: a constraint the model does not also state is a
|
|
349
|
+
constraint the browser cannot show. Add the validator too.
|
|
350
|
+
|
|
351
|
+
#### Match Bootstrap with field_error_proc
|
|
352
|
+
|
|
353
|
+
- Wherever Bootstrap is the CSS framework, set
|
|
354
|
+
`config.action_view.field_error_proc`. Rails' default wraps a rejected field in
|
|
355
|
+
`<div class='field_with_errors'>`, which Bootstrap styles not at all: no red
|
|
356
|
+
border, and the message nowhere on the page.
|
|
357
|
+
- The proc adds `is-invalid` to the control and follows it with a
|
|
358
|
+
`<small class='invalid-feedback'>`, which is the pair Bootstrap needs — its
|
|
359
|
+
`.is-invalid ~ .invalid-feedback` reveals one only next to the other.
|
|
360
|
+
- Guard on the control's class, not on the tag's type. A label carries
|
|
361
|
+
`form-label` and falls straight through, and so does anything else without a
|
|
362
|
+
`form-control`. Guarding on `instance.is_a? ActionView::Helpers::Tags::Label`
|
|
363
|
+
instead leaves `html_tag.index 'form-control'` returning nil for every other
|
|
364
|
+
kind of tag, and `insert nil` raises.
|
|
365
|
+
- The proc is `instance_exec`'d on the view, so `tag` and `safe_join` are in
|
|
366
|
+
scope — no need to write markup as a string.
|
|
367
|
+
- Which is just as well, because `insert` on a SafeBuffer escapes what it is
|
|
368
|
+
given: an attribute spliced in by hand arrives as `'`. Build it with
|
|
369
|
+
`tag.attributes` and join it with `safe_join`.
|
|
370
|
+
- A rule for apps we write. The gem never sets a host's Action View config, the
|
|
371
|
+
same line drawn for the logger and the time zone — so a control the gem draws
|
|
372
|
+
outside a form builder, like the combobox, carries this markup itself.
|
|
373
|
+
|
|
374
|
+
#### Every model says how it is labelled
|
|
375
|
+
|
|
376
|
+
- A model answers `recourse_label` with the column a combobox shows for one of its
|
|
377
|
+
records. `Recourse::Recoursive` supplies `:name`, and every Active Record model
|
|
378
|
+
is extended with it through `ActiveSupport.on_load :active_record`, so most
|
|
379
|
+
models need say nothing at all.
|
|
380
|
+
- A model whose identity is not a `name` overrides it — `:code` for a ZIP, `:email`
|
|
381
|
+
for an Agent — but never in the model body. It `include`s its own `Recoursive`
|
|
382
|
+
concern, in `app/models/zip/recoursive.rb`, which overrides inside
|
|
383
|
+
`class_methods do`. The default arrives by `extend`, so only a class method can
|
|
384
|
+
replace it.
|
|
385
|
+
- The label is what gets selected: `select(:id, label).order(label)`, per "select
|
|
386
|
+
only the columns a query displays". So it has to be a real column, not a method —
|
|
387
|
+
a method would not survive the `SELECT`.
|
|
388
|
+
- Reading it back is `recourse.attributes[label]`, not `public_send`, which "no
|
|
389
|
+
metaprogramming" rules out.
|
|
390
|
+
- Picking an encrypted column labels the option with its plaintext, since
|
|
391
|
+
`attributes` decrypts. That is a decision to make deliberately, not to fall into,
|
|
392
|
+
and it reaches further than a form: a foreign-key column in a *table* shows the
|
|
393
|
+
same label, so an encrypted one appears on the index of every model that
|
|
394
|
+
references it. `resource_columns` only keeps a model's own encrypted columns out.
|
|
395
|
+
- `recourse_typed_label?` asks whether that label has a length validator, which is
|
|
396
|
+
what decides between typing a value and picking from a list. A length is the only
|
|
397
|
+
honest signal available: it says the value is bounded, so a person can type it.
|
|
398
|
+
- A typed label is looked up on the way in — `ZIP.find_by code: '90210'` — and the
|
|
399
|
+
form asks for it under the foreign key's own name, so no host model needs a
|
|
400
|
+
virtual attribute and strong parameters need no special case.
|
|
401
|
+
|
|
402
|
+
#### Vendor what a page cannot render without
|
|
403
|
+
|
|
404
|
+
- A stylesheet or script a page cannot do without is vendored into the gem and
|
|
405
|
+
served from it, never linked to a CDN. A host that fails to reach the CDN gets
|
|
406
|
+
an unstyled page, and the Bootstrap 6 CSS in particular comes from a preview
|
|
407
|
+
host with no promise of staying put.
|
|
408
|
+
- The files live in `vendor/recourse/`, and an engine initializer serves them with
|
|
409
|
+
`Rack::Static`. That is the framework's own middleware rather than a controller
|
|
410
|
+
action, and it assumes no asset pipeline, which a host may well not have.
|
|
411
|
+
- Keep the slash on the prefix. `urls: %w[/recourse/]` matches on `start_with?`,
|
|
412
|
+
so `urls: %w[/recourse]` would answer `/recourses` with a 404 from the file
|
|
413
|
+
server before the router ever saw it — in this gem of all places.
|
|
414
|
+
- Vendor whatever the vendored file itself asks for. `bootstrap-icons.min.css`
|
|
415
|
+
loads `fonts/bootstrap-icons.woff2` relative to itself, so the CSS without the
|
|
416
|
+
fonts renders every icon as a blank box.
|
|
417
|
+
- Keep the copies byte-identical to what the CDN serves, so a later version can
|
|
418
|
+
be diffed against upstream. `git ls-files` puts them in the gem already.
|
|
419
|
+
- Our own JavaScript is not vendored. It lives in `app/javascript/recourse/` and
|
|
420
|
+
is served at the same prefix: the first `Rack::Static` takes `cascade: true`, so
|
|
421
|
+
a path it has no file for falls through to the second rather than 404ing. That
|
|
422
|
+
keeps `vendor/` meaning "upstream's", which is what exempts it from the lint.
|
|
423
|
+
- A Stimulus controller imports Stimulus by its served path, not by the bare
|
|
424
|
+
`@hotwired/stimulus` specifier. Resolving that name would need an import map,
|
|
425
|
+
and a host app may already ship one of its own.
|
|
426
|
+
- Start the application in the `<head>`, and guard it with `window.Stimulus`.
|
|
427
|
+
Turbo re-runs body scripts on every visit, and a second application connects
|
|
428
|
+
every controller a second time.
|
|
429
|
+
|
|
430
|
+
#### Seed data lives in migrations, so schema.rb cannot load a database
|
|
431
|
+
|
|
432
|
+
- `config.active_record.dump_schema_after_migration = false`, so no `schema.rb`
|
|
433
|
+
is ever written. Gitignoring it is not enough: it regenerates on every migrate
|
|
434
|
+
and then Rails loads it into the next empty database, stamping every version at
|
|
435
|
+
or below its own as already migrated — silently skipping the backfills and
|
|
436
|
+
leaving the tables empty for the next foreign key to trip over.
|
|
437
|
+
- Build a database with `db:migrate` from zero. Never `db:schema:load`, and be
|
|
438
|
+
wary of `db:prepare` for the same reason.
|
|
439
|
+
- `db:drop` will not drop a database with open connections and reports success
|
|
440
|
+
anyway; `dropdb --force` is the reliable reset.
|
|
441
|
+
|
|
442
|
+
#### Design lives in STYLE.md
|
|
443
|
+
|
|
444
|
+
- Every decision about how a page looks — Bootstrap conventions, class choices,
|
|
445
|
+
markup structure — is documented in `STYLE.md`, not here. Read that file
|
|
446
|
+
before writing or editing any layout, view or partial.
|
|
447
|
+
- This file stays the authority for code style. Where the two overlap, `STYLE.md`
|
|
448
|
+
wins on markup and `CLAUDE.md` wins on Ruby.
|
|
449
|
+
|
|
450
|
+
### READABILITY
|
|
451
|
+
|
|
452
|
+
#### Files at most 100 lines
|
|
453
|
+
|
|
454
|
+
- No code file goes over 100 lines, counting blank and comment lines. When one
|
|
455
|
+
gets close, split it — extract a class, a concern, a partial, a second test
|
|
456
|
+
case.
|
|
457
|
+
- Enforced by `rake file_length`, part of the default task. RuboCop has no
|
|
458
|
+
file-length cop; `Metrics/ClassLength` and friends measure a class body, not a
|
|
459
|
+
file, and skip comments and blanks by default.
|
|
460
|
+
- `.md`, `.txt`, `.html` and `.erb` are exempt. Prose is not code, and a view
|
|
461
|
+
is markup whose length is driven by the page, not by design choices.
|
|
462
|
+
- Anything under `db/migrate/` is exempt. A migration that backfills a table is
|
|
463
|
+
as long as the data it carries, and splitting one to satisfy a line count
|
|
464
|
+
would be worse than leaving it long.
|
|
465
|
+
- So is anything under `vendor/`. Upstream's formatting is not ours to fix, and a
|
|
466
|
+
vendored font is not even text — `File.readlines` on a `.woff2` reports
|
|
467
|
+
thousands of lines that mean nothing.
|
|
468
|
+
- The task reads `git ls-files`, so an untracked file is invisible to it. A green
|
|
469
|
+
run before `git add` proves nothing about what the commit will contain.
|
|
470
|
+
|
|
471
|
+
#### Lines at most 100 characters
|
|
472
|
+
|
|
473
|
+
- Hard limit of 100 characters per line, enforced by RuboCop's
|
|
474
|
+
`Layout/LineLength` (`Max: 100`, up from its default of 120).
|
|
475
|
+
- Split long strings across lines with `\` continuations rather than letting a
|
|
476
|
+
line run over.
|
|
477
|
+
- Views are exempt — `.html` and `.erb` files may run past 100 characters,
|
|
478
|
+
since a CDN URL or a long class list cannot be wrapped usefully. RuboCop does
|
|
479
|
+
not lint them anyway.
|
|
480
|
+
- When a method call would need three lines and hanging indentation just to fit,
|
|
481
|
+
hoist the long arguments into a Rails `with_options` block instead. Still
|
|
482
|
+
three lines, but every line starts at a normal indent:
|
|
483
|
+
|
|
484
|
+
with_options format: { with: SOME_PATTERN, message: 'is invalid' } do
|
|
485
|
+
validates :phone, allow_nil: true
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
#### Shared behavior becomes a concern
|
|
489
|
+
|
|
490
|
+
- When two models declare the same behavior word for word, extract it into a
|
|
491
|
+
concern instead of leaving the copy in place.
|
|
492
|
+
`encrypts :email, deterministic: true, downcase: true` stood in both `Contact`
|
|
493
|
+
and `Agent`, so it became `Emailable`.
|
|
494
|
+
- Name the concern after the feature it carries, not after the models that want
|
|
495
|
+
it: `Emailable`, `Phonable`.
|
|
496
|
+
- Only what the models genuinely share moves. `Contact`'s email is optional and
|
|
497
|
+
`Agent`'s is required, so `presence: true` stays in each model — the same line
|
|
498
|
+
`Phonable` already draws for `phone`.
|
|
499
|
+
- This sharpens the baseline's "concerns for genuinely shared behavior": a second
|
|
500
|
+
identical declaration is the threshold, and anticipating one is not.
|
|
501
|
+
|
|
502
|
+
#### As few parentheses as possible
|
|
503
|
+
|
|
504
|
+
- Omit parentheses on a method call's arguments; keep the inner ones, where
|
|
505
|
+
parsing needs them:
|
|
506
|
+
|
|
507
|
+
Object.const_set class_name, Class.new(RecoursesController)
|
|
508
|
+
|
|
509
|
+
- Enforced by `Style/MethodCallWithArgsParentheses` with
|
|
510
|
+
`EnforcedStyle: omit_parentheses`. The cop is off by default, so it needs
|
|
511
|
+
`Enabled: true` as well as the style.
|
|
512
|
+
|
|
513
|
+
#### List concerns alphabetically, on one line
|
|
514
|
+
|
|
515
|
+
- Concerns are included in alphabetical order: `include Emailable, Phonable`,
|
|
516
|
+
never the other way round.
|
|
517
|
+
- One `include` carries the whole list. Give each its own statement only when
|
|
518
|
+
the single line would not fit, and then keep the order.
|
|
519
|
+
- Enforced by `Style/MixinGrouping` with `EnforcedStyle: grouped`. Its default
|
|
520
|
+
is `separated`, which demands the opposite, so the setting is not optional.
|
|
521
|
+
- `include A, B` inserts them in reverse, so `A` ends up ahead of `B` in
|
|
522
|
+
`ancestors`. It only matters when both define the same method, which two
|
|
523
|
+
concerns that were extracted for being distinct features should not.
|
|
524
|
+
|
|
525
|
+
#### Pass locals to partials explicitly
|
|
526
|
+
|
|
527
|
+
- A partial never reads a controller's instance variables. Declare strict
|
|
528
|
+
locals on its first line — `<%# locals: (resources:, pagy:) %>` — and pass
|
|
529
|
+
them at the call site: `render 'table', resources: @resources, pagy: @pagy`.
|
|
530
|
+
- A partial that takes no locals gets no comment at all. Never write
|
|
531
|
+
`<%# locals: () %>`.
|
|
532
|
+
- A template rendered by an action may read instance variables. The rule is
|
|
533
|
+
about partials, which should not depend on who rendered them.
|
|
534
|
+
- Rails enforces this: omit a declared local and the render raises instead of
|
|
535
|
+
quietly rendering a blank.
|
|
536
|
+
- Where two branches need different locals, write `if`/`else` rather than
|
|
537
|
+
`render cond ? 'a' : 'b'` — a single call cannot pass the right locals to
|
|
538
|
+
both.
|
|
539
|
+
- The row partial is the deliberate exception, and it breaks the rule twice.
|
|
540
|
+
Its record arrives under a name computed at runtime (`contact:`, `state:`),
|
|
541
|
+
so the gem's own `_row` cannot declare strict locals and reads
|
|
542
|
+
`local_assigns[resource_key]`. And whether it is drawing the header row or a
|
|
543
|
+
body row travels in `@recourse_headers`, set by `_table` before each render,
|
|
544
|
+
which `column` reads. Both were asked for; neither is a pattern to copy.
|
|
545
|
+
- The fields partial is the second exception, for the same two reasons. It is
|
|
546
|
+
handed the record under the runtime name so a host's `_fields` can declare
|
|
547
|
+
`<%# locals: (contact:) -%>`, while the gem's own cannot; and the form builder
|
|
548
|
+
travels in `@recourse_form`, set by `_form`, because `field :phone` is the
|
|
549
|
+
call site the host writes and threading a form through it would spoil that.
|
|
550
|
+
|
|
551
|
+
#### Spell acronyms as acronyms
|
|
552
|
+
|
|
553
|
+
- An acronym is written in capitals wherever it appears: ZIP code, not Zip code;
|
|
554
|
+
PIN, not Pin. That covers prose, comments, class names and labels alike.
|
|
555
|
+
- When a model or column names one, register it so Rails agrees:
|
|
556
|
+
`inflect.acronym 'ZIP'` in `config/initializers/inflections.rb`. Without it
|
|
557
|
+
`human_attribute_name` renders `Zip` and every heading and label is wrong.
|
|
558
|
+
- Registering it also fixes `camelize`, so `zip_code` becomes `ZIPCode` rather
|
|
559
|
+
than `ZipCode` — worth knowing before naming a class after one.
|
|
560
|
+
|
|
561
|
+
#### Name non-trivial regular expressions
|
|
562
|
+
|
|
563
|
+
- A regular expression that is not obvious at a glance gets a named constant,
|
|
564
|
+
so the name explains the intent and the pattern is stated once.
|
|
565
|
+
`/\A[2-9]\d{2}[2-9]\d{6}\z/` becomes `NORTH_AMERICAN_PHONES`.
|
|
566
|
+
- Put the constant on the class or module that owns the rule, and comment it
|
|
567
|
+
with what it accepts and rejects — the name says what, the comment says why
|
|
568
|
+
those bounds.
|
|
569
|
+
- Trivial patterns used once, like `%r{\Aexe/}`, stay inline.
|
|
570
|
+
|
|
571
|
+
#### Comment every public declaration
|
|
572
|
+
|
|
573
|
+
- Never comment a private method. The rule below is for the public surface; a
|
|
574
|
+
private method earns its explanation from its name and its caller.
|
|
575
|
+
- Never put a method in a controller that only a view calls, and never reach for
|
|
576
|
+
`helper_method` to expose one. If a view needs it, it belongs in a helper
|
|
577
|
+
module or inline in the template. A controller's private methods are for the
|
|
578
|
+
controller's own work.
|
|
579
|
+
- Indent `private` to match its `class` or `module`, not the `def`s under it, so
|
|
580
|
+
it stands out as a divider. Enforced by
|
|
581
|
+
`Layout/AccessModifierIndentation: EnforcedStyle: outdent`.
|
|
582
|
+
- Precede every public class, module, constant and method declaration with a
|
|
583
|
+
comment line saying what that object does. This narrows the baseline's
|
|
584
|
+
"comment why, not what" rule: declarations get a *what*, and the *why* rule
|
|
585
|
+
still governs comments inside method bodies.
|
|
586
|
+
- Say what it is for, not what the code plainly shows. `# Raised for every
|
|
587
|
+
failure the gem reports` earns its place; `# The Error class` does not.
|
|
588
|
+
- A module reopened purely as a namespace in another file is not redeclared —
|
|
589
|
+
document it where it is defined.
|
|
590
|
+
- Enforced by RuboCop: `Style/Documentation` for classes and modules,
|
|
591
|
+
`Style/DocumentationMethod` (off by default) for methods. Both skip `test/`,
|
|
592
|
+
matching RuboCop's own default, so test cases stay uncommented — their names
|
|
593
|
+
state the expectation.
|
|
594
|
+
- Constants have no cop; keep them commented by hand.
|
|
595
|
+
- Keep these comments to a single line whenever possible. If one line cannot
|
|
596
|
+
carry it, cut the aside rather than the rule — the detail belongs in the
|
|
597
|
+
commit message. Multi-line is a last resort, not a default.
|
|
598
|
+
|
|
599
|
+
#### Never freeze strings
|
|
600
|
+
|
|
601
|
+
- Never write `# frozen_string_literal: true`. No file gets a magic comment,
|
|
602
|
+
including generated ones — strip it from generator output.
|
|
603
|
+
- Never call `.freeze` on a string, constant or not. Array and hash constants
|
|
604
|
+
are still worth freezing by hand.
|
|
605
|
+
- Where a constant only names something, prefer a symbol over a string — it is
|
|
606
|
+
immutable already, so the question does not arise.
|
|
607
|
+
- Enforced by RuboCop: `Style/FrozenStringLiteralComment` is `never`, and
|
|
608
|
+
`Style/MutableConstant` is disabled because it demands `.freeze` on string
|
|
609
|
+
constants and cannot be told to skip them.
|
|
610
|
+
|
|
611
|
+
#### Single quotes by default
|
|
612
|
+
|
|
613
|
+
- Always use single-quoted strings.
|
|
614
|
+
- Double quotes only when the string genuinely needs them: interpolation
|
|
615
|
+
(`"#{name}"`) or escape sequences (`"\n"`, `"\x0"`).
|
|
616
|
+
- Enforced by RuboCop: `Style/StringLiterals` and
|
|
617
|
+
`Style/StringLiteralsInInterpolation` are both set to `single_quotes`.
|
|
618
|
+
- This covers views too, `.html` and `.html.erb` included, and applies to HTML
|
|
619
|
+
attributes and CSS values as much as to Ruby: `<th scope='col'>`, not
|
|
620
|
+
`<th scope="col">`. RuboCop does not lint views, so this half is on us.
|
|
621
|
+
- A Ruby string containing single quotes then *needs* double quotes, which is
|
|
622
|
+
why assertions on this markup read `"<table class='table table-hover'>"`.
|
|
623
|
+
|
|
624
|
+
### INTERNATIONALIZATION
|
|
625
|
+
|
|
626
|
+
#### Eastern time
|
|
627
|
+
|
|
628
|
+
- `config.time_zone = 'Eastern Time (US & Canada)'`. That is what `Time.zone`
|
|
629
|
+
means, what a form reads, and what a timestamp renders as.
|
|
630
|
+
- Storage stays UTC. Never touch `config.active_record.default_timezone` — the
|
|
631
|
+
database keeps UTC and Rails converts on the way in and out, so the app zone
|
|
632
|
+
is a display concern only.
|
|
633
|
+
- A rule for apps we write. The gem never sets a host's time zone.
|
|
634
|
+
|
|
635
|
+
#### I18n is deferred
|
|
636
|
+
|
|
637
|
+
- User-facing strings stay plain English for now. This suspends the baseline's
|
|
638
|
+
"I18n for user-facing strings" rule until there are enough strings to be worth
|
|
639
|
+
a locale file — do not add one unprompted.
|
|
640
|
+
|
|
641
|
+
#### The State model
|
|
642
|
+
|
|
643
|
+
- A `State` model always represents the United States, and always has exactly
|
|
644
|
+
these three attributes, each non-null and unique: `code` (two capital
|
|
645
|
+
letters, `'CA'`), `fips` (two digits, `'06'`), `name` (`'California'`).
|
|
646
|
+
- It always ships with a migration that creates the table *and* backfills it
|
|
647
|
+
from the official list, so an app never starts with an empty states table.
|
|
648
|
+
Source of truth: https://www2.census.gov/geo/docs/reference/state.txt
|
|
649
|
+
- 51 rows: the 50 states plus the District of Columbia. Territories are not
|
|
650
|
+
states, so `PR`, `GU`, `VI`, `AS`, `MP` and `UM` are left out.
|
|
651
|
+
- `fips` is a string, never an integer — `'06'` must keep its leading zero.
|
|
652
|
+
- Enforce all of it in the database too: unique indexes, `null: false`,
|
|
653
|
+
`limit: 2`, and check constraints for the two-letter and two-digit shapes.
|
|
654
|
+
|
|
655
|
+
#### The County model
|
|
656
|
+
|
|
657
|
+
- A `County` has a unique non-null 5-digit `fips` string, a non-null `name`, and
|
|
658
|
+
belongs to a `state`. `name` is deliberately *not* unique — more than twenty
|
|
659
|
+
states have a Washington County.
|
|
660
|
+
- Creating a counties table always comes with a migration that backfills all
|
|
661
|
+
3,143 counties of the 50 states plus DC, each joined to the right `states` row.
|
|
662
|
+
Source: https://www2.census.gov/geo/docs/reference/codes2020/national_county2020.txt
|
|
663
|
+
- The first two digits of a county's `fips` are its state's `fips`. Check that
|
|
664
|
+
after backfilling, not just the row count — by hand, since a migration does
|
|
665
|
+
not get a test.
|
|
666
|
+
- Territories are left out, matching the states table.
|
|
667
|
+
- The 3,143 rows live in `db/counties.txt`, not inside the migration. Migrations
|
|
668
|
+
are exempt from the file-length rule, so this is a decision rather than a
|
|
669
|
+
workaround: leave the data in the file and do not inline it.
|
|
670
|
+
- The database enforces it too: unique index on `fips`, `null: false`, a
|
|
671
|
+
five-digit check constraint, and a real foreign key to `states`.
|
|
672
|
+
|
|
673
|
+
#### The ZIP model
|
|
674
|
+
|
|
675
|
+
- A `ZIP` has a unique non-null 5-digit `code`, a non-null `city`, a non-null
|
|
676
|
+
`time_zone`, belongs to a `county`, and optionally belongs to a `market`.
|
|
677
|
+
- The class is `ZIP`, not `Zip`, because the acronym is registered. Register the
|
|
678
|
+
plural too — `inflect.acronym 'ZIPs'` — or every heading reads `Zips`.
|
|
679
|
+
- Registering the plural renames more than headings: Rails camelizes a migration
|
|
680
|
+
filename to find its class, so `create_zips.rb` must define `CreateZIPs`. It
|
|
681
|
+
only breaks on a migrate from zero, which is why a reset is the real test.
|
|
682
|
+
- Creating a zips table always comes with a migration that backfills it: every
|
|
683
|
+
ZIP, matched to the county it mostly belongs to and the main city in it.
|
|
684
|
+
- `time_zone` holds a Rails zone name, never an IANA identifier. The source mixes
|
|
685
|
+
both, so the backfill normalizes on the way in, matching each identifier by its
|
|
686
|
+
offset and DST rules: Detroit and the Kentucky zones to Eastern, Indiana's
|
|
687
|
+
Eastern zones to `Indiana (East)`, Knox and Tell_City to Central, Boise to
|
|
688
|
+
Mountain, Anchorage and Nome to Alaska, Honolulu to Hawaii. Nothing should
|
|
689
|
+
survive that `ActiveSupport::TimeZone::MAPPING` does not name — worth checking
|
|
690
|
+
by hand after a backfill, since a migration does not get a test.
|