railbow 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c42f0f6d8162662344a6ef8fb12388e8dd6a2a579384cab3829a85d806cb1571
4
- data.tar.gz: 064fb908f8cd7567e9d8be53800e6972aa48faf65348aff7338c6088aa7427dd
3
+ metadata.gz: 55d44fb326ce90c9bc4e843104639b49c2d96c866401bb53c962f226166bb22f
4
+ data.tar.gz: 808baadb49bf2e7d96586a1a809501bdbe87cfa454db019d184ca36804cc813a
5
5
  SHA512:
6
- metadata.gz: f7af392359a26db27919f6bac5d31ce22d6736615bbbe8d514c794e5114a2a71ff61081160340a3177fd4b172207b8c07ae2948fcfdaeaccbc0498b28370fb1c
7
- data.tar.gz: e5a9364c62c93fa530cbde3222c2249b3f251043514fda4d4cf51a33cfaf27ad54bffed5d5abae43befe065634faee0d96ec60581adc22004cd45790e7f87534
6
+ metadata.gz: c256343dee985473f88c27aa26e0bb827587d49001eaf82f7a8d85dbf3a56197d65ba12e3641d64c87cfbcac290a2b787020d1850a53ce0e8fbf746dc2d3ef9e
7
+ data.tar.gz: 4d5740b3a9a98aa692b7b031fa26a811709c4724c996ee9dfd00f0dfc3f339bf431499c27c3af46b354a7aeb7ae9bd95503f99402b1d5e44f3b1fc6314cd64a1
data/CHANGELOG.md CHANGED
@@ -5,6 +5,47 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.0] - 2026-07-28
9
+
10
+ ### Added
11
+
12
+ - `RBW_CALENDAR=wdividers` gives every ISO week its own separator row, the
13
+ fuller counterpart to `wticks`. A week that opens a new month is left to the
14
+ month row rather than being announced twice. The default stays `wticks`, so
15
+ existing output is unchanged until you opt in.
16
+ - `RBW_CALENDAR=counts` appends `· N migrations` to every separator row: how
17
+ many migrations that section holds, counted up to the next separator.
18
+ - `RBW_CALENDAR=wlabel:<fmt>` sets the strftime pattern for week rows,
19
+ alongside the existing `label:` for month rows. It defaults to the month
20
+ pattern, so the week number sits at the same offset on every separator row
21
+ rather than shifting left when no new month is announced.
22
+ - `railbow demo status` now honors `RBW_CALENDAR`, so the calendar modes can be
23
+ tried without a Rails project.
24
+ - An empty `RBW_CALENDAR` (month separators, no week markers) is now documented
25
+ in the help output, the `railbow init` template, and the README. It always
26
+ worked; nobody could have guessed it.
27
+
28
+ ### Changed
29
+
30
+ - Calendar separator rows are drawn in a muted purple (256-color 97) instead of
31
+ the brighter 141, so they frame the migrations without competing with them.
32
+ - Migrations that are not applied (`down`) now render as a greyed-out row in
33
+ `db:migrate:status`, instead of signalling their state through the status
34
+ column alone. The row keeps its layout and its status glyph keeps its color;
35
+ everything else drops its own colors, so pending migrations read as inactive
36
+ next to the applied ones.
37
+
38
+ ### Fixed
39
+
40
+ - Rows whose last column wrapped were indented with too wide a blank prefix:
41
+ building the row mutated the very cell that the wrapped-line indent is
42
+ measured from. Reachable from `routes`, `about`, and `stats`.
43
+ - A separator label wider than its column no longer pushes the rest of the row
44
+ sideways. Separator rows are drawn as empty walls with the label laid over
45
+ them, so a long label spills into the blank space to its right.
46
+ - `RBW_HELP` output for `db:migrate:status` uses plain hyphens instead of
47
+ em-dashes, matching the rest of the user-facing text.
48
+
8
49
  ## [0.3.0] - 2026-07-23
9
50
 
10
51
  ### Added
data/README.md CHANGED
@@ -21,7 +21,7 @@
21
21
  - **Notes** - `rails notes` with git blame, author colors, date filtering, and sorting
22
22
  - **About** - polished `rails about` output
23
23
  - **Git Integration** - authors, diffs, branch origin, landing dates, uncommitted file indicators
24
- - **Calendar View** - month separators and week tick markers for migration timelines
24
+ - **Calendar View** - month separators plus week ticks or full week rows, with optional per-section counts
25
25
  - **Ghost Recovery** - uses the [mighost](https://github.com/amberpixels/mighost) gem (optional) to recover names, authors, and branch badges for `NO FILE` migrations
26
26
  - **Smart Defaults** - auto-disables in CI, piped output, `NO_COLOR`, and LLM agents
27
27
 
@@ -105,6 +105,46 @@ Out of the box you get:
105
105
  - **Affected tables** - color-coded table names extracted from migration files
106
106
  - **Time filtering** - only the last 70 days shown by default (`since: 70d`)
107
107
  - **Your migrations highlighted** - rows authored by you are visually distinct
108
+ - **Pending migrations greyed out** - a `down` row keeps its status glyph but loses its colors
109
+
110
+ #### Weekly view
111
+
112
+ By default each new ISO week is marked with a tick on the date column
113
+ (`calendar: wticks`). For a fuller weekly breakdown, `wdividers` gives every
114
+ week its own separator row, and `counts` appends how many migrations sit under
115
+ each separator, week or month:
116
+
117
+ ```bash
118
+ RBW_CALENDAR=wdividers,counts rake db:migrate:status
119
+ ```
120
+
121
+ ```
122
+ Feb 2026 W06 · 2 migrations
123
+ ↑↑ │ 20260202145343 │ 2026-02-02 14:53:43 │ Add weight field to animals ● animals
124
+ ↑↑ │ 20260203134528 │ 2026-02-03 13:45:28 │ Create vaccination records ● vaccination_records
125
+ Feb 2026 W07 · 1 migration
126
+ ↑↑ │ 20260210183902 │ 2026-02-10 18:39:02 │ Create pet tags ● pet_tags
127
+ Mar 2026 W10 · 1 migration
128
+ ↑↑ │ 20260303120000 │ 2026-03-03 12:00:00 │ Add breed restrictions to… ● adoption_policies
129
+ Mar 2026 W11 · 1 migration
130
+ ↑↑ │ 20260313132325 │ 2026-03-13 13:23:25 │ Create veterinary appointm… ⎇ PS-142 ● veterinary_appointments
131
+ ```
132
+
133
+ Every separator carries the month, so the week number always sits at the same
134
+ offset instead of shifting left on rows that open no new month, and every
135
+ separator is drawn in the same muted purple. Both labels are `strftime`
136
+ patterns: `label:` for months, `wlabel:` for weeks (defaulting to the same
137
+ pattern).
138
+
139
+ `RBW_CALENDAR` is the list of *week* markers, so an empty value leaves you with
140
+ month separators and nothing else:
141
+
142
+ ```yaml
143
+ view: "calendar,tables"
144
+ calendar: "" # month separators only
145
+ ```
146
+
147
+ Dropping `calendar` from `view` instead removes the month separators too.
108
148
 
109
149
  If the [mighost](https://github.com/amberpixels/mighost) gem is installed (`gem "mighost", group: [:development, :test]`), migrations whose files were deleted (e.g. after switching branches) show up with a 👻 status and their recovered name instead of a bare `********** NO FILE **********` row - Railbow talks to it through the stable `Mighost::API`. Each ghost row carries the most informative badge mighost can provide: `≡ <version>` when the migration was re-timestamped and lives on under another version (shown with a calmer 🪦 status), `⌥ <branch>` when a branch still holds the file, or `✂ deleted in:<sha>` pointing at the commit that removed it. Ghosts dismissed via `mighost:dismiss` (or hidden by `hide_superseded`) render as plain `NO FILE`.
110
150
 
@@ -181,7 +221,7 @@ Every option can also be set via `RBW_*` environment variables, which override c
181
221
  | `RBW_DATE` | `full`, `rel`, `short`, `custom(%b %d)` | Date display format |
182
222
  | `RBW_GIT` | `author:me,diff,mask:auto` | Git integration options |
183
223
  | `RBW_VIEW` | `calendar,tables` | Enable calendar view and table detection |
184
- | `RBW_CALENDAR` | `wticks` | Show week tick markers |
224
+ | `RBW_CALENDAR` | `wticks`, `wdividers,counts`, `` (empty) | Week markers: tick marks, full week separator rows, per-section counts. Empty means month separators only |
185
225
  | `RBW_COMPACT` | `oneline,dense,noheader,maxw:80` | Compact display options |
186
226
  | `RBW_VERB` | `GET,POST` | Filter routes by HTTP method |
187
227
  | `RBW_SORT` | `file`, `date` | Sort order for notes |
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Railbow
6
+ # Turns an ordered list of migration versions into the calendar furniture that
7
+ # gives the status table its shape: a separator row when the month changes,
8
+ # optionally one when the ISO week changes, and optionally a tick mark on the
9
+ # date column at each week boundary.
10
+ #
11
+ # A separator introduces the rows beneath it and runs until the next separator,
12
+ # so the optional count always answers "how many migrations in this section".
13
+ module Calendar
14
+ DEFAULT_MONTH_LABEL = "%b %Y W%V"
15
+ # Week rows carry the month too, so the week number sits at the same offset
16
+ # on every separator row rather than jumping left when the month is absent.
17
+ DEFAULT_WEEK_LABEL = DEFAULT_MONTH_LABEL
18
+
19
+ # What the renderer needs to draw the calendar: a label per separator row,
20
+ # and which rows open a week (for tick marks). Both keyed by row index.
21
+ class Furniture
22
+ attr_reader :separators, :tick_rows
23
+
24
+ def initialize
25
+ @separators = {}
26
+ @tick_rows = Set.new
27
+ end
28
+ end
29
+
30
+ module_function
31
+
32
+ def none
33
+ Furniture.new
34
+ end
35
+
36
+ def build(versions, weeks: false, ticks: false, counts: false,
37
+ month_label: DEFAULT_MONTH_LABEL, week_label: DEFAULT_WEEK_LABEL)
38
+ dates = versions.map { |v| parse_date(v) }
39
+ furniture = none
40
+
41
+ dates.each_with_index do |date, i|
42
+ prev = (i > 0) ? dates[i - 1] : nil
43
+ next unless date && prev
44
+
45
+ opens_week = week_key(prev) != week_key(date)
46
+
47
+ if month_key(prev) != month_key(date)
48
+ furniture.separators[i] = date.strftime(month_label)
49
+ elsif weeks && opens_week
50
+ furniture.separators[i] = date.strftime(week_label)
51
+ end
52
+
53
+ furniture.tick_rows << i if ticks && opens_week
54
+ end
55
+
56
+ append_counts(furniture.separators, dates.size) if counts
57
+ furniture
58
+ end
59
+
60
+ def parse_date(version)
61
+ v = version.to_s
62
+ Date.new(v[0..3].to_i, v[4..5].to_i, v[6..7].to_i)
63
+ rescue Date::Error
64
+ nil
65
+ end
66
+
67
+ def month_key(date)
68
+ [date.year, date.month]
69
+ end
70
+
71
+ # cwyear, not year: the ISO week of Jan 1 can belong to the year before.
72
+ def week_key(date)
73
+ [date.cwyear, date.cweek]
74
+ end
75
+
76
+ def append_counts(separators, total)
77
+ indices = separators.keys.sort
78
+ indices.each_with_index do |index, nth|
79
+ section_end = indices[nth + 1] || total
80
+ separators[index] = "#{separators[index]} · #{migration_count(section_end - index)}"
81
+ end
82
+ end
83
+
84
+ # Spelled out: a bare number next to a week label reads as part of the date.
85
+ def migration_count(count)
86
+ "#{count} #{(count == 1) ? "migration" : "migrations"}"
87
+ end
88
+ end
89
+ end
@@ -3,7 +3,9 @@
3
3
  require "date"
4
4
  require_relative "../formatters/base"
5
5
  require_relative "../table"
6
+ require_relative "../calendar"
6
7
  require_relative "../config"
8
+ require_relative "../params"
7
9
  require_relative "fixtures"
8
10
 
9
11
  module Railbow
@@ -20,7 +22,8 @@ module Railbow
20
22
  puts "\n#{formatter.emoji(:status)} Database: #{formatter.cyan("db/development.sqlite3")}"
21
23
  puts
22
24
 
23
- # Use default config: author:me, diff, calendar, tables, wticks, full date
25
+ # Fixed for the showcase: author:me, diff, tables, full date. The calendar
26
+ # options below come from the real params, so RBW_CALENDAR still applies.
24
27
  date_format = "full"
25
28
  tables_enabled = true
26
29
  diff_enabled = true
@@ -28,7 +31,7 @@ module Railbow
28
31
  git_email = Fixtures::DEMO_USER_EMAIL
29
32
  git_name = Fixtures::DEMO_USER_NAME
30
33
 
31
- # Latest migration date for "fresh" landed badge detection
34
+ # Latest migration date - for "fresh" landed badge detection
32
35
  latest_version = migrations.last[:version]
33
36
  latest_mig_date = parse_version_date(latest_version)
34
37
 
@@ -43,7 +46,7 @@ module Railbow
43
46
  name_col_width = needs_name_truncation ? 60 : nil
44
47
 
45
48
  table_columns = [
46
- Railbow::Table::Column.new(label: "Status", max_width: 6, sticky: true),
49
+ Railbow::Table::Column.new(label: "Status", max_width: 6, sticky: true, accent: true),
47
50
  Railbow::Table::Column.new(label: "Migration ID", sticky: true),
48
51
  Railbow::Table::Column.new(label: "Created At"),
49
52
  Railbow::Table::Column.new(label: "Migration Name",
@@ -57,7 +60,9 @@ module Railbow
57
60
 
58
61
  # Build rows
59
62
  highlight_rows = Set.new
63
+ down_rows = Set.new
60
64
  rows = migrations.each_with_index.map do |m, idx|
65
+ down_rows << idx if m[:status] == "down"
61
66
  colored_status = case m[:status]
62
67
  when "up" then formatter.green_bold("up")
63
68
  when "down" then formatter.yellow_bold("down")
@@ -106,40 +111,16 @@ module Railbow
106
111
  [colored_status, m[:version], created_at, display_name, table_tags]
107
112
  end
108
113
 
109
- # Calendar separators
110
- separators = {}
111
- versions = migrations.map { |m| m[:version] }
112
- month_keys = versions.map { |v| v[0..5] }
113
- calendar_label_fmt = "%b %Y W%V"
114
-
115
- if month_keys.uniq.size > 1
116
- month_keys.each_with_index do |mk, i|
117
- next if i == 0
118
- if mk != month_keys[i - 1]
119
- v = versions[i]
120
- date = Date.new(v[0..3].to_i, v[4..5].to_i, v[6..7].to_i)
121
- separators[i] = date.strftime(calendar_label_fmt)
122
- end
123
- end
124
- end
125
-
126
- # Week ticks
127
- tick_rows = Set.new
128
- prev_week = nil
129
- versions.each_with_index do |v, i|
130
- y = v[0..3].to_i
131
- m = v[4..5].to_i
132
- d = v[6..7].to_i
133
- next if y == 0 || m == 0 || d == 0
134
-
135
- week = Date.new(y, m, d).cweek
136
-
137
- if i > 0 && prev_week && week != prev_week
138
- tick_rows << i
139
- end
140
-
141
- prev_week = week
142
- end
114
+ # Calendar furniture. Driven by the real params, so RBW_CALENDAR can be
115
+ # tried out here without a Rails project.
116
+ calendar = Railbow::Calendar.build(
117
+ migrations.map { |m| m[:version] },
118
+ weeks: Railbow::Params.calendar_wdividers?,
119
+ ticks: Railbow::Params.calendar_wticks?,
120
+ counts: Railbow::Params.calendar_counts?,
121
+ month_label: Railbow::Params.calendar_label,
122
+ week_label: Railbow::Params.calendar_week_label
123
+ )
143
124
 
144
125
  aliases = Railbow::Config.table_aliases
145
126
  renderer = Railbow::Table::Renderer.new(
@@ -148,7 +129,10 @@ module Railbow
148
129
  compact: {oneline: false, dense: false, noheader: false, maxw: nil, hidden_columns: []},
149
130
  aliases: aliases
150
131
  )
151
- puts renderer.render(rows, separators: separators, highlight_rows: highlight_rows, tick_rows: tick_rows, tick_col: 2)
132
+ puts renderer.render(rows,
133
+ separators: calendar.separators,
134
+ highlight_rows: highlight_rows, dim_rows: down_rows,
135
+ tick_rows: calendar.tick_rows, tick_col: 2)
152
136
  end
153
137
 
154
138
  private
data/lib/railbow/init.rb CHANGED
@@ -43,7 +43,13 @@ module Railbow
43
43
  # View mode (comma-separated): calendar, tables
44
44
  view: "calendar,tables"
45
45
 
46
- # Calendar options: wticks (show week tick separators)
46
+ # Calendar options (comma-separated), all require view: calendar.
47
+ # Leave empty ("") for month separators only, with no week markers.
48
+ # wticks - week tick marks on the date column
49
+ # wdividers - a separator row per ISO week
50
+ # counts - append "· N migrations" to separator rows (per section)
51
+ # label:<fmt> - strftime for month separators (default: %b %Y W%V)
52
+ # wlabel:<fmt> - strftime for week separators (default: same as label)
47
53
  calendar: "wticks"
48
54
 
49
55
  # Date format: full, rel, short, or custom(%b %d, %Y)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "date"
4
+ require_relative "calendar"
4
5
  require_relative "config"
5
6
  require_relative "name_formatter"
6
7
  require_relative "name_collision_resolver"
@@ -226,8 +227,26 @@ module Railbow
226
227
  calendar["wticks"] == true
227
228
  end
228
229
 
230
+ # A separator row per ISO week, the fuller counterpart to wticks.
231
+ def calendar_wdividers?
232
+ return false unless view_calendar?
233
+
234
+ calendar["wdividers"] == true
235
+ end
236
+
237
+ # Append "· N migrations" to every separator row: how many it introduces.
238
+ def calendar_counts?
239
+ return false unless view_calendar?
240
+
241
+ calendar["counts"] == true
242
+ end
243
+
229
244
  def calendar_label
230
- calendar["label"] || "%b %Y W%V"
245
+ calendar["label"] || Calendar::DEFAULT_MONTH_LABEL
246
+ end
247
+
248
+ def calendar_week_label
249
+ calendar["wlabel"] || Calendar::DEFAULT_WEEK_LABEL
231
250
  end
232
251
  end
233
252
  end
@@ -3,9 +3,11 @@
3
3
  module Railbow
4
4
  module Table
5
5
  class Column
6
- attr_reader :label, :width, :min_width, :max_width, :align, :truncate, :truncate_fn, :sticky
6
+ attr_reader :label, :width, :min_width, :max_width, :align, :truncate, :truncate_fn, :sticky, :accent
7
7
 
8
- def initialize(label:, width: :auto, min_width: nil, max_width: nil, align: :left, truncate: false, truncate_fn: nil, sticky: false)
8
+ # accent: the column carries its own meaning through color (a status
9
+ # glyph, say), so it keeps that color when the row is dimmed.
10
+ def initialize(label:, width: :auto, min_width: nil, max_width: nil, align: :left, truncate: false, truncate_fn: nil, sticky: false, accent: false)
9
11
  @label = label
10
12
  @width = width
11
13
  @min_width = min_width
@@ -14,6 +16,7 @@ module Railbow
14
16
  @truncate = truncate
15
17
  @truncate_fn = truncate_fn
16
18
  @sticky = sticky
19
+ @accent = accent
17
20
  end
18
21
 
19
22
  def fixed?
@@ -10,8 +10,9 @@ module Railbow
10
10
 
11
11
  RESET = "\e[0m"
12
12
  WHITE = "\e[97m"
13
- GHOST_BG = "\e[48;5;52m" # deep red/maroon background stands out as abnormal
13
+ GHOST_BG = "\e[48;5;52m" # deep red/maroon background - stands out as abnormal
14
14
  GHOST_FG = "\e[38;5;217m" # warm pink foreground for contrast
15
+ DIMMED_FG = "\e[38;5;242m" # muted grey - a row that is not currently in effect
15
16
 
16
17
  attr_reader :columns, :theme
17
18
 
@@ -23,7 +24,7 @@ module Railbow
23
24
  @theme = theme
24
25
  end
25
26
 
26
- def render(rows, separators: {}, highlight_rows: Set.new, ghost_rows: Set.new, tick_rows: Set.new, tick_col: nil)
27
+ def render(rows, separators: {}, highlight_rows: Set.new, ghost_rows: Set.new, dim_rows: Set.new, tick_rows: Set.new, tick_col: nil)
27
28
  return "" if columns.empty?
28
29
 
29
30
  # Remap rows if columns were hidden
@@ -64,12 +65,11 @@ module Railbow
64
65
  rows.each_with_index do |row, i|
65
66
  tc = (tick_rows.include?(i) && tick_col) ? tick_col : nil
66
67
  if separators.key?(i) && theme.format_separator
67
- sep_row = Array.new(columns.size, "")
68
- sep_row[1] = theme.format_separator.call(separators[i]) if columns.size > 1
69
- lines << render_row(sep_row, resolved, tick_col: tc, tick_cross: true)
68
+ lines << render_separator_row(separators[i], resolved, tick_col: tc)
70
69
  tc = nil # tick already shown on separator row
71
70
  end
72
- formatted = render_row(row, resolved, tick_col: tc, highlight: highlight_rows.include?(i), ghost: ghost_rows.include?(i))
71
+ formatted = render_row(row, resolved, tick_col: tc, highlight: highlight_rows.include?(i),
72
+ ghost: ghost_rows.include?(i), dim: dim_rows.include?(i))
73
73
  lines << formatted
74
74
  end
75
75
  lines.join("\n")
@@ -112,44 +112,65 @@ module Railbow
112
112
  }.join(theme.header_col_separator)
113
113
  end
114
114
 
115
- def render_row(row, widths, tick_col: nil, tick_cross: false, highlight: false, ghost: false)
115
+ # A separator row is furniture, not data: the walls are drawn empty and the
116
+ # label is laid over them starting at the second column. Writing over the
117
+ # blanks rather than filling a cell means a label wider than its column
118
+ # spills into the empty space to its right instead of shifting the row.
119
+ def render_separator_row(label, widths, tick_col: nil)
120
+ pad = effective_padding
121
+ cells = widths.map { |w| "#{pad}#{" " * w}#{pad}" }
122
+
123
+ skeleton = cells.first.to_s.dup
124
+ (1...cells.size).each do |i|
125
+ skeleton << "#{separator_at(i - 1, tick_col, cross: true)}#{cells[i]}"
126
+ end
127
+ return skeleton if cells.size < 2 || label.nil? || label.empty?
128
+
129
+ offset = display_width(cells[0]) + display_width(separator_at(0, tick_col, cross: true)) + display_width(pad)
130
+ tail = skeleton[(offset + display_width(label))..] || ""
131
+ "#{skeleton[0, offset]}#{theme.format_separator.call(label)}#{tail}"
132
+ end
133
+
134
+ # The separator character between column index and index + 1. Columns
135
+ # flanking the tick column get the tick variant so the week line reads
136
+ # as one continuous rule.
137
+ def separator_at(index, tick_col, cross: false)
138
+ flanks_tick = tick_col && (index == tick_col - 1 || index == tick_col)
139
+ return theme.col_separator unless flanks_tick
140
+
141
+ cross ? theme.tick_cross_separator : theme.tick_separator
142
+ end
143
+
144
+ def render_row(row, widths, tick_col: nil, highlight: false, ghost: false, dim: false)
116
145
  last = columns.size - 1
117
146
  pad = effective_padding
118
- default_sep = theme.col_separator
119
- tick_sep = tick_cross ? theme.tick_cross_separator : theme.tick_separator
120
147
 
121
148
  prefix_parts = row[0...last].each_with_index.map { |cell, i|
122
149
  s = cell.to_s
123
150
  cell_w = display_width(strip_ansi(s))
124
151
  padding = " " * [widths[i] - cell_w, 0].max
125
152
  content = (columns[i].align == :right) ? "#{padding}#{s}" : "#{s}#{padding}"
126
- if ghost
127
- content = "#{GHOST_BG}#{GHOST_FG}#{content}#{RESET}"
128
- elsif highlight
129
- content = "#{WHITE}#{content}#{RESET}"
130
- end
153
+ content = style_cell(content, highlight: highlight, ghost: ghost, dim: dim, accent: columns[i].accent)
131
154
  "#{pad}#{content}#{RESET}#{pad}"
132
155
  }
133
156
 
134
- # Join prefix parts with per-position separators
135
- # Loop index i joins column i-1 and column i (separator_index = i-1)
136
- # For tick_col, flanking separator indices are tick_col-1 and tick_col
137
- prefix = prefix_parts.first.to_s
157
+ # Join prefix parts with per-position separators.
158
+ # Loop index i joins column i-1 and column i (separator index = i-1).
159
+ # dup: appending to prefix_parts.first itself would corrupt the parts
160
+ # that render_last_cell measures for the wrapped-line indent.
161
+ prefix = prefix_parts.first.to_s.dup
138
162
  (1...prefix_parts.size).each do |i|
139
- sep_idx = i - 1
140
- sep = (tick_col && (sep_idx == tick_col - 1 || sep_idx == tick_col)) ? tick_sep : default_sep
141
- prefix << "#{sep}#{prefix_parts[i]}"
163
+ prefix << "#{separator_at(i - 1, tick_col)}#{prefix_parts[i]}"
142
164
  end
143
165
 
144
166
  last_cell_raw = row[last].to_s
145
167
 
146
168
  # Separator before the last column has index (last - 1)
147
- last_sep_idx = last - 1
148
- last_sep = (tick_col && (last_sep_idx == tick_col - 1 || last_sep_idx == tick_col)) ? tick_sep : default_sep
149
- render_last_cell(prefix, prefix_parts, last_cell_raw, widths, last, col_sep: last_sep, highlight: highlight, ghost: ghost)
169
+ last_sep = separator_at(last - 1, tick_col)
170
+ render_last_cell(prefix, prefix_parts, last_cell_raw, widths, last, col_sep: last_sep, highlight: highlight, ghost: ghost, dim: dim)
150
171
  end
151
172
 
152
- def render_last_cell(prefix, prefix_parts, last_cell_raw, widths, last, col_sep: nil, highlight: false, ghost: false)
173
+ def render_last_cell(prefix, prefix_parts, last_cell_raw, widths, last, col_sep: nil, highlight: false, ghost: false, dim: false)
153
174
  pad = effective_padding
154
175
  sep = col_sep || theme.col_separator
155
176
 
@@ -167,7 +188,7 @@ module Railbow
167
188
  if columns[last].truncate_fn && last_col_max &&
168
189
  display_width(last_cell_plain) > last_col_max
169
190
  last_cell_raw = columns[last].truncate_fn.call(last_cell_raw, last_col_max)
170
- last_cell_raw = style_cell(last_cell_raw, highlight: highlight, ghost: ghost)
191
+ last_cell_raw = style_cell(last_cell_raw, highlight: highlight, ghost: ghost, dim: dim, accent: columns[last].accent)
171
192
  return "#{prefix}#{sep}#{pad}#{last_cell_raw}#{RESET}#{pad}"
172
193
  end
173
194
 
@@ -175,18 +196,18 @@ module Railbow
175
196
  if columns[last].truncate && !columns[last].max_width && last_col_max &&
176
197
  display_width(last_cell_plain) > last_col_max
177
198
  last_cell_raw = truncate_by_words(last_cell_raw, last_col_max)
178
- last_cell_raw = style_cell(last_cell_raw, highlight: highlight, ghost: ghost)
199
+ last_cell_raw = style_cell(last_cell_raw, highlight: highlight, ghost: ghost, dim: dim, accent: columns[last].accent)
179
200
  return "#{prefix}#{sep}#{pad}#{last_cell_raw}#{RESET}#{pad}"
180
201
  end
181
202
 
182
203
  # In oneline mode, truncate instead of wrapping
183
204
  if @compact[:oneline] && last_col_max && display_width(last_cell_plain) > last_col_max
184
205
  last_cell_raw = truncate_by_words(last_cell_raw, last_col_max)
185
- last_cell_raw = style_cell(last_cell_raw, highlight: highlight, ghost: ghost)
206
+ last_cell_raw = style_cell(last_cell_raw, highlight: highlight, ghost: ghost, dim: dim, accent: columns[last].accent)
186
207
  return "#{prefix}#{sep}#{pad}#{last_cell_raw}#{RESET}#{pad}"
187
208
  end
188
209
 
189
- last_cell_raw = style_cell(last_cell_raw, highlight: highlight, ghost: ghost)
210
+ last_cell_raw = style_cell(last_cell_raw, highlight: highlight, ghost: ghost, dim: dim, accent: columns[last].accent)
190
211
 
191
212
  if last_col_max && display_width(strip_ansi(last_cell_raw)) > last_col_max
192
213
  blank_prefix = prefix_parts.map { |part|
@@ -201,9 +222,17 @@ module Railbow
201
222
  end
202
223
  end
203
224
 
204
- def style_cell(content, highlight: false, ghost: false)
225
+ def style_cell(content, highlight: false, ghost: false, dim: false, accent: false)
205
226
  if ghost
206
227
  "#{GHOST_BG}#{GHOST_FG}#{content}#{RESET}"
228
+ elsif dim
229
+ # An accent column keeps its color - on a dimmed row it is the one
230
+ # cell still carrying meaning. Everything else drops its own colors:
231
+ # one flat grey is what reads as "not in effect", and the resets that
232
+ # end each inner color would break the grey run anyway.
233
+ return content if accent
234
+
235
+ "#{DIMMED_FG}#{strip_ansi(content)}#{RESET}"
207
236
  elsif highlight
208
237
  "#{WHITE}#{content}#{RESET}"
209
238
  else
@@ -315,7 +344,7 @@ module Railbow
315
344
  current << token
316
345
  current_width += token_width
317
346
  elsif current_width.zero?
318
- # Single token wider than max try to break on underscores
347
+ # Single token wider than max - try to break on underscores
319
348
  broken = break_long_token(token, max_width)
320
349
  lines.concat(broken[0...-1])
321
350
  current = +broken.last
@@ -8,7 +8,8 @@ module Railbow
8
8
  :format_header_cell, :format_separator
9
9
 
10
10
  def initialize(col_separator:, header_col_separator:, cell_padding:,
11
- format_header_cell:, format_separator: nil, tick_separator: nil, tick_cross_separator: nil)
11
+ format_header_cell:, format_separator: nil,
12
+ tick_separator: nil, tick_cross_separator: nil)
12
13
  @col_separator = col_separator
13
14
  @tick_separator = tick_separator || col_separator
14
15
  @tick_cross_separator = tick_cross_separator || @tick_separator
@@ -23,7 +24,7 @@ module Railbow
23
24
  RESET = "\e[0m"
24
25
  BOLD = "\e[1m"
25
26
  WHITE = "\e[97m"
26
- PURPLE = "\e[38;5;141m"
27
+ PURPLE = "\e[38;5;97m" # separator rows: present, but never louder than the data
27
28
  BG_PURPLE = "\e[48;5;99m"
28
29
  DIM = "\e[2m"
29
30
 
@@ -6,6 +6,7 @@ require_relative "../formatters/base"
6
6
  require_relative "../migration_parser"
7
7
  require_relative "../config"
8
8
  require_relative "../table"
9
+ require_relative "../calendar"
9
10
  require_relative "../logo"
10
11
 
11
12
  # Override DatabaseTasks.migrate_status which is called by both
@@ -270,7 +271,7 @@ module Railbow
270
271
  # 1. Filter out child branches: if branch A is an ancestor of branch B,
271
272
  # the commit was introduced in A, not B.
272
273
  # 2. Among remaining, prefer the branch with the MOST commits after the
273
- # adding commit it has been active longer since the commit was made,
274
+ # adding commit - it has been active longer since the commit was made,
274
275
  # indicating it is the original branch (not a newer fork).
275
276
  best = if branches.size == 1
276
277
  branches.first
@@ -376,36 +377,44 @@ module Railbow
376
377
  Units: d (days), w (weeks), mo/m (months), y (years)
377
378
 
378
379
  RBW_DATE=<mode> Date column format (default: full):
379
- full 2026-01-30 12:08:54 (column: Created At)
380
- rel ~3d ago
381
- short Jan 30 (column: Date)
382
- custom(…) user strftime, e.g. custom(%b %d, %Y)
380
+ full - 2026-01-30 12:08:54 (column: Created At)
381
+ rel - ~3d ago
382
+ short - Jan 30 (column: Date)
383
+ custom(…) - user strftime, e.g. custom(%b %d, %Y)
383
384
 
384
385
  RBW_VIEW=<options> Display options (comma-separated):
385
- calendar show month/year separator lines + week ticks
386
- tables parse migration files, show Tables column
386
+ calendar - show month/year separator lines + week ticks
387
+ tables - parse migration files, show Tables column
387
388
 
388
389
  RBW_COMPACT=<options> Compact display (comma-separated):
389
- oneline truncate instead of wrapping
390
- dense remove cell padding
391
- noheader hide table header row
392
- maxw:<n> cap column widths at n chars
393
- hide:<col> hide a column by name (repeatable)
394
-
395
- RBW_CALENDAR=<options> Calendar sub-options (requires RBW_VIEW=calendar):
396
- wticks — show week tick marks on date column
397
- label:<fmt> strftime format for month separator
398
- (default: %b %Y W%V)
390
+ oneline - truncate instead of wrapping
391
+ dense - remove cell padding
392
+ noheader - hide table header row
393
+ maxw:<n> - cap column widths at n chars
394
+ hide:<col> - hide a column by name (repeatable)
395
+
396
+ RBW_CALENDAR=<options> Calendar sub-options (requires RBW_VIEW=calendar):
397
+ (empty) - month separators only, no week
398
+ markers at all
399
+ wticks - week tick marks on the date column
400
+ wdividers - a separator row per ISO week
401
+ counts - append "· N migrations" to every
402
+ separator row (that section)
403
+ label:<fmt> - strftime for month separators
404
+ (default: %b %Y W%V)
405
+ wlabel:<fmt> - strftime for week separators
406
+ (default: same as label, so the
407
+ week number never shifts)
399
408
 
400
409
  RBW_GIT=<options> Git integration (comma-separated):
401
- author add an Author column (same as author:all)
402
- author:all add an Author column
403
- author:me highlight your own migrations
404
- diff tag migrations by git origin
405
- base:<branch> base branch for diff (default: auto-detected)
406
- mask:<re> regex to extract branch label
410
+ author - add an Author column (same as author:all)
411
+ author:all - add an Author column
412
+ author:me - highlight your own migrations
413
+ diff - tag migrations by git origin
414
+ base:<branch> - base branch for diff (default: auto-detected)
415
+ mask:<re> - regex to extract branch label
407
416
  e.g. mask:(WS-[^/]+)/
408
- mask:auto auto-extract ticket id from branch name
417
+ mask:auto - auto-extract ticket id from branch name
409
418
 
410
419
  RBW_PLAIN=1 Disable Railbow formatting (plain Rails output)
411
420
 
@@ -419,6 +428,7 @@ module Railbow
419
428
  \e[1mExamples:\e[0m
420
429
  rake db:migrate:status
421
430
  RBW_SINCE=2mo RBW_VIEW=calendar rake db:migrate:status
431
+ RBW_CALENDAR=wdividers,counts rake db:migrate:status
422
432
  RBW_VIEW=tables RBW_GIT=author rake db:migrate:status
423
433
  RBW_GIT=author:me RBW_SINCE=3mo rake db:migrate:status
424
434
  RBW_DATE=rel rake db:migrate:status
@@ -481,7 +491,7 @@ module Railbow
481
491
 
482
492
  skipped = total_count - db_list.size
483
493
  if skipped > 0
484
- puts formatter.dim(" (#{skipped} older migrations hidden SINCE=#{since_value})")
494
+ puts formatter.dim(" (#{skipped} older migrations hidden - SINCE=#{since_value})")
485
495
  puts
486
496
  end
487
497
  end
@@ -545,7 +555,7 @@ module Railbow
545
555
  end
546
556
 
547
557
  # Build columns
548
- # Latest migration ID date used to determine "fresh" landed badges
558
+ # Latest migration ID date - used to determine "fresh" landed badges
549
559
  latest_version = db_list.last&.dig(1).to_s
550
560
  latest_mig_date = begin
551
561
  Date.new(latest_version[0..3].to_i, latest_version[4..5].to_i, latest_version[6..7].to_i)
@@ -565,7 +575,7 @@ module Railbow
565
575
  needs_name_truncation = tables_enabled || author_mode == "all" || diff_enabled || has_landed_tags
566
576
  name_col_width = needs_name_truncation ? 60 : nil
567
577
  table_columns = [
568
- Railbow::Table::Column.new(label: "Status", max_width: 6, sticky: true),
578
+ Railbow::Table::Column.new(label: "Status", max_width: 6, sticky: true, accent: true),
569
579
  Railbow::Table::Column.new(label: "Migration ID", sticky: true),
570
580
  Railbow::Table::Column.new(label: (date_format == "full") ? "Created At" : "Date"),
571
581
  Railbow::Table::Column.new(label: "Migration Name",
@@ -590,10 +600,14 @@ module Railbow
590
600
  {}
591
601
  end
592
602
 
593
- # Build rows and track highlight/ghost indices
603
+ # Build rows and track highlight/ghost/down indices
594
604
  highlight_rows = Set.new
595
605
  ghost_rows = Set.new
606
+ down_rows = Set.new
596
607
  rows = db_list.each_with_index.map do |(status, version, name), idx|
608
+ # A pending migration is not in effect yet: grey the whole row out so it
609
+ # reads as inactive next to the applied ones.
610
+ down_rows << idx if status == "down"
597
611
  colored_status = case status
598
612
  when "up" then formatter.green_bold("up")
599
613
  when "down" then formatter.yellow_bold("down")
@@ -602,7 +616,7 @@ module Railbow
602
616
  ghost_snapshot = name.include?("NO FILE") ? mighost_snapshots[version.to_s] : nil
603
617
  if name.include?("NO FILE") && ghost_snapshot
604
618
  ghost_rows << idx
605
- # Mighost recovered this ghost migration show ghost status + name + badge.
619
+ # Mighost recovered this ghost migration - show ghost status + name + badge.
606
620
  # A superseded ghost lives on under another version: stale bookkeeping,
607
621
  # not a lost migration, so it gets a calmer glyph.
608
622
  colored_status = ghost_snapshot.superseded_by ? "🪦" : "👻"
@@ -701,7 +715,7 @@ module Railbow
701
715
  filepath = version_to_file[version.to_s]
702
716
  basename = filepath ? File.basename(filepath) : nil
703
717
 
704
- # Uncommitted migrations have no git author treat them as mine.
718
+ # Uncommitted migrations have no git author - treat them as mine.
705
719
  # Match by email first; fall back to author name to handle cases where
706
720
  # the commit email differs from git config (e.g. GitHub noreply emails
707
721
  # after squash-merge, or mailmap rewrites).
@@ -736,44 +750,18 @@ module Railbow
736
750
  row
737
751
  end
738
752
 
739
- # Calendar separators
740
- separators = {}
741
- if calendar_enabled
742
- versions = db_list.map { |_, v, _| v.to_s }
743
- month_keys = versions.map { |v| v[0..5] }
744
- calendar_label_fmt = Railbow::Params.calendar_label
745
-
746
- if month_keys.uniq.size > 1
747
- month_keys.each_with_index do |mk, i|
748
- next if i == 0
749
- if mk != month_keys[i - 1]
750
- v = versions[i]
751
- date = Date.new(v[0..3].to_i, v[4..5].to_i, v[6..7].to_i)
752
- separators[i] = date.strftime(calendar_label_fmt)
753
- end
754
- end
755
- end
756
- end
757
-
758
- # Week tick separators: mark first row of each new ISO week
759
- tick_rows = Set.new
760
- if ticks_enabled
761
- versions = db_list.map { |_, v, _| v.to_s }
762
- prev_week = nil
763
- versions.each_with_index do |v, i|
764
- y = v[0..3].to_i
765
- m = v[4..5].to_i
766
- d = v[6..7].to_i
767
- next if y == 0 || m == 0 || d == 0
768
-
769
- week = Date.new(y, m, d).cweek
770
-
771
- if i > 0 && prev_week && week != prev_week
772
- tick_rows << i
773
- end
774
-
775
- prev_week = week
776
- end
753
+ # Calendar furniture: month separators, week separators, week ticks
754
+ calendar = if calendar_enabled
755
+ Railbow::Calendar.build(
756
+ db_list.map { |_, v, _| v.to_s },
757
+ weeks: Railbow::Params.calendar_wdividers?,
758
+ ticks: ticks_enabled,
759
+ counts: Railbow::Params.calendar_counts?,
760
+ month_label: Railbow::Params.calendar_label,
761
+ week_label: Railbow::Params.calendar_week_label
762
+ )
763
+ else
764
+ Railbow::Calendar.none
777
765
  end
778
766
 
779
767
  renderer = Railbow::Table::Renderer.new(
@@ -783,7 +771,10 @@ module Railbow
783
771
  aliases: Railbow::Config.table_aliases
784
772
  )
785
773
  tick_col = 2 # Date column index
786
- puts renderer.render(rows, separators: separators, highlight_rows: highlight_rows, ghost_rows: ghost_rows, tick_rows: tick_rows, tick_col: tick_col)
774
+ puts renderer.render(rows,
775
+ separators: calendar.separators,
776
+ highlight_rows: highlight_rows, ghost_rows: ghost_rows, dim_rows: down_rows,
777
+ tick_rows: calendar.tick_rows, tick_col: tick_col)
787
778
  end
788
779
  end
789
780
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Railbow
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: railbow
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eugene M
@@ -78,6 +78,7 @@ files:
78
78
  - exe/railbow
79
79
  - lib/railbow.rb
80
80
  - lib/railbow/about_formatter.rb
81
+ - lib/railbow/calendar.rb
81
82
  - lib/railbow/color_assigner.rb
82
83
  - lib/railbow/config.rb
83
84
  - lib/railbow/demo/fixtures.rb