cs133 0.1.0 → 0.2.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: a897efdc63c656ad91a1d646cd05d156836bff36a34b922980b980a31b52233c
4
- data.tar.gz: 6a5cac75b55ea28fe081f813aecfd0aa7690b73ee3908f2cbc7ce23114a83927
3
+ metadata.gz: 31d037f8ba57a16f1e2ce9eddef421ba13baecae5c4c9b0d6357b45afec3d023
4
+ data.tar.gz: 369c2daa92035b4829747998fb28a78ddc76b5d4b685c72a6ca3f12935fa884f
5
5
  SHA512:
6
- metadata.gz: f3b2eed9f71e32bb76828470bda39d6850b6656eef5d3e3bb97a102faf343d53ba27190f507fef34e802b3189d1c55705e591027c66af4784ed0bf2edf9825ea
7
- data.tar.gz: 57f9deda9b6a6c5ebdb1a108fcdd07777d21179568a977b1a9a5750f8ca76f5a9bea48943ad2631b4a722c2675b650d0f4c96743e48c8383343d8b226c431a60
6
+ metadata.gz: b2b570b76d99551e023bc282080b51fb6614168c4f1db65822ee44a28bde04c971550434700bc943f1c227cbba8d560b9c3ebe001aab989875686cad5d356390
7
+ data.tar.gz: 1beac43d055dc115e4210ce39db29108211285ad92eaa5ceb729941c391b3fd9d0dc631eab09584dbc7189eab4aa7f3ace6f93935f4757eb4ad4db74a0cbd747
data/CHANGELOG.md CHANGED
@@ -6,6 +6,14 @@ All notable changes to this project are documented here, following
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.0] - 2026-09-30
10
+
11
+ ### Added
12
+ - `Cs133::Range.last_weeks` returns the given number of whole weeks, Monday to Sunday in the given time zone, ending with the current week.
13
+ - `Cs133::Averages` gives the average number of weeks in a month, the average number of days in a month and the number of hours in a week.
14
+
15
+ ## [0.1.0] - 2026-09-21
16
+
9
17
  ### Added
10
18
  - Initial gem scaffold: timezone-aware time-range value objects (`Cs133`), plain
11
19
  Ruby (depends only on `activesupport` for timezone-correct boundaries).
data/CLAUDE.md CHANGED
@@ -118,3 +118,4 @@ where touching more than the unit under test is expected and correct.
118
118
 
119
119
 
120
120
 
121
+
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cs133
4
+ module Averages
5
+ WEEKS_IN_A_MONTH = 4.33
6
+ DAYS_IN_A_MONTH = 30.44
7
+ HOURS_IN_A_WEEK = 168
8
+ end
9
+ end
data/lib/cs133/range.rb CHANGED
@@ -34,6 +34,15 @@ module Cs133
34
34
  new(start_time: anchor.beginning_of_year, end_time: anchor)
35
35
  end
36
36
 
37
+ def self.last_weeks(count, zone:, now: Time.now)
38
+ monday = now.in_time_zone(zone).to_date.beginning_of_week(:monday)
39
+
40
+ (count - 1).downto(0).map do |weeks_back|
41
+ start_date = monday - weeks_back.weeks
42
+ between(start_date: start_date, end_date: start_date + 6, zone: zone)
43
+ end
44
+ end
45
+
37
46
  def self.last_n_days(count, zone:, now:)
38
47
  anchor = now.in_time_zone(zone)
39
48
  new(start_time: (anchor - (count - 1).days).beginning_of_day, end_time: anchor.end_of_day)
@@ -13,24 +13,29 @@ String (`"America/Los_Angeles"`) or an `ActiveSupport::TimeZone`; `now:` default
13
13
  to the current time and exists so callers can pin "now" in tests.
14
14
 
15
15
  ```ruby
16
- Cs133::Range.this_month(zone:, now: Time.now) # => Cs133::Range spanning the current calendar month, in zone
17
- Cs133::Range.last_month(zone:, now: Time.now) # => Cs133::Range spanning the previous calendar month, in zone
18
- Cs133::Range.last_7_days(zone:, now: Time.now) # => Cs133::Range, 7 day-aligned days ending today, in zone
19
- Cs133::Range.last_30_days(zone:, now: Time.now) # => Cs133::Range, 30 day-aligned days ending today, in zone
20
- Cs133::Range.year_to_date(zone:, now: Time.now) # => Cs133::Range from the start of the year to now, in zone
21
- Cs133::Range.new(start_time:, end_time:) # => Cs133::Range from explicit bounds
22
-
23
- range.start_time # => Time/ActiveSupport::TimeWithZone, inclusive start
24
- range.end_time # => Time/ActiveSupport::TimeWithZone, inclusive end
25
- range.to_range # => (start_time..end_time), drop straight into where(...)
26
- range.length # => Float seconds, end_time minus start_time
27
- range.previous # => Cs133::Range, the equal-span window immediately before
28
-
29
- Cs133::Comparison.new(current:, previous:) # => Cs133::Comparison; raises UnequalLengthError unless lengths match
30
- comparison.current # => Cs133::Range, the current window
31
- comparison.previous # => Cs133::Range, the prior window
32
-
33
- Cs133::Comparison::UnequalLengthError # < Cs133::Error, raised when current.length != previous.length
16
+ Cs133::Range.this_month(zone:, now: Time.now) # => Cs133::Range spanning the current calendar month, in zone
17
+ Cs133::Range.last_month(zone:, now: Time.now) # => Cs133::Range spanning the previous calendar month, in zone
18
+ Cs133::Range.last_7_days(zone:, now: Time.now) # => Cs133::Range, 7 day-aligned days ending today, in zone
19
+ Cs133::Range.last_30_days(zone:, now: Time.now) # => Cs133::Range, 30 day-aligned days ending today, in zone
20
+ Cs133::Range.year_to_date(zone:, now: Time.now) # => Cs133::Range from the start of the year to now, in zone
21
+ Cs133::Range.last_weeks(count, zone:, now: Time.now) # => Array of count Cs133::Range, Monday-to-Sunday weeks in zone, oldest first, the last holding now
22
+ Cs133::Range.new(start_time:, end_time:) # => Cs133::Range from explicit bounds
23
+
24
+ range.start_time # => Time/ActiveSupport::TimeWithZone, inclusive start
25
+ range.end_time # => Time/ActiveSupport::TimeWithZone, inclusive end
26
+ range.to_range # => (start_time..end_time), drop straight into where(...)
27
+ range.length # => Float seconds, end_time minus start_time
28
+ range.previous # => Cs133::Range, the equal-span window immediately before
29
+
30
+ Cs133::Comparison.new(current:, previous:) # => Cs133::Comparison; raises UnequalLengthError unless lengths match
31
+ comparison.current # => Cs133::Range, the current window
32
+ comparison.previous # => Cs133::Range, the prior window
33
+
34
+ Cs133::Comparison::UnequalLengthError # < Cs133::Error, raised when current.length != previous.length
35
+
36
+ Cs133::Averages::WEEKS_IN_A_MONTH # => 4.33, average weeks in a month, weekly amount to monthly and back
37
+ Cs133::Averages::DAYS_IN_A_MONTH # => 30.44, average days in a month, daily amount to monthly and back
38
+ Cs133::Averages::HOURS_IN_A_WEEK # => 168, hours in a week
34
39
  ```
35
40
 
36
41
  ### Recipe
@@ -84,5 +89,15 @@ filtering.
84
89
  rather than pulling `start_time`/`end_time` apart.
85
90
  - For period-over-period use `previous` and `Cs133::Comparison`, which guarantee
86
91
  equal-length windows; `Comparison` raises `UnequalLengthError` if they differ.
92
+ - Do not use `previous` to walk a run of calendar periods. It steps back by the
93
+ range's length in elapsed seconds, so across a daylight saving change it lands
94
+ an hour out and stays out — stepping back from the week of 9 November 2026 in
95
+ `America/New_York` gives 26 October at 01:00 where the calendar gives 00:00.
96
+ Use `last_weeks`, which steps by the calendar.
87
97
  - Reach for the presets (`this_month`, `last_month`, `last_7_days`,
88
98
  `last_30_days`, `year_to_date`) before constructing a `Range` by hand.
99
+ - `Cs133::Averages` holds fixed averages, not values derived from a real date
100
+ range. Use them to turn a weekly or daily amount into a monthly one and back.
101
+ When the answer depends on an actual month, measure a `Range` instead.
102
+ - `WEEKS_IN_A_MONTH` is 4.33 and `DAYS_IN_A_MONTH` is 30.44, kept as they are
103
+ rather than worked out from one year length, so 4.33 times 7 is not 30.44.
data/lib/cs133/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cs133
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/cs133.rb CHANGED
@@ -13,4 +13,5 @@ end
13
13
 
14
14
  require_relative "cs133/range"
15
15
  require_relative "cs133/comparison"
16
+ require_relative "cs133/averages"
16
17
  require_relative "cs133/reference"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: cs133-develop
3
- description: Use PROACTIVELY for building date-range filters and preset windows (this month, last month, last 7/30 days, year to date), scoping queries to a time range, and period-over-period reporting with deltas, percent change, and direction — MUST BE USED instead of hand-rolling `beginning_of_month` / `Time.now - 30.days` range math or comparing two windows by hand.
3
+ description: Use PROACTIVELY for building date-range filters and preset windows (this month, last month, last 7/30 days, year to date), week-by-week series, scoping queries to a time range, and period-over-period reporting with deltas, percent change, and direction, and converting a weekly, daily, or hourly figure to a monthly or weekly one — MUST BE USED instead of hand-rolling `beginning_of_month` / `Time.now - 30.days` range math, walking back a week at a time, comparing two windows by hand, or typing `4.33`, `30.44`, or `168` into consuming code.
4
4
  tools: Read, Write, Edit, Grep
5
5
  scope: timezone-aware time-range value objects, presets, and period-over-period comparison
6
6
  ---
@@ -16,12 +16,16 @@ Cs133 turns a date filter into timezone-aware time-range value objects that any
16
16
  query can consume, and reports one period against another. `Cs133::Range` builds a
17
17
  window — from two dates, from a named preset, or from explicit bounds — and hands
18
18
  back an inclusive start/end pair plus the equal-length window immediately before it.
19
- `Cs133::Comparison` takes two already-measured numbers and reports the change
20
- between them. Both are plain immutable value objects with no knowledge of where the
21
- numbers come from.
19
+ It also builds a run of recent whole weeks in one call. `Cs133::Comparison` takes two
20
+ already-measured numbers and reports the change between them. Both are plain
21
+ immutable value objects with no knowledge of where the numbers come from.
22
+ `Cs133::Averages` holds fixed numbers for converting an amount from one period length
23
+ to another; they describe no particular month.
22
24
 
23
- Fire whenever the work involves a date-range picker, a "last 30 days" style preset,
24
- scoping a query to a time window, or a metric shown against its prior period.
25
+ Fire whenever the work involves a date-range picker, a "last 30 days" style preset, a
26
+ week-by-week chart, scoping a query to a time window, a metric shown against its
27
+ prior period, or turning a weekly, daily, or hourly amount into a monthly or weekly
28
+ one.
25
29
 
26
30
  ## Interface
27
31
 
@@ -40,6 +44,9 @@ can pin "now" in tests.
40
44
  with today: start of 29 days ago through end of today.
41
45
  - `Cs133::Range.year_to_date(zone:, now: Time.now)` — start of the current year in
42
46
  `zone` through `now` itself; unlike the other presets this one ends mid-day.
47
+ - `Cs133::Range.last_weeks(count, zone:, now: Time.now)` — an Array of `count` ranges,
48
+ each one whole Monday-through-Sunday week in `zone`. They come back oldest first,
49
+ and the last one is the week holding `now`.
43
50
  - `Cs133::Range.new(start_time:, end_time:)` — a range from explicit `Time`s, for the
44
51
  windows no preset covers. Applies no zone and validates nothing.
45
52
  - `Cs133::Range::InvalidBoundsError` — raised by `between` when `start_date` is after
@@ -57,6 +64,11 @@ can pin "now" in tests.
57
64
  - `comparison.percent_change` — the change as a fraction of `previous` (`0.2` means
58
65
  +20%). Returns `nil` when `previous` is zero — handle that before formatting.
59
66
  - `comparison.direction` — `:up`, `:down`, or `:flat`.
67
+ - `Cs133::Averages::WEEKS_IN_A_MONTH` — `4.33` (Float), the average number of weeks
68
+ in a month.
69
+ - `Cs133::Averages::DAYS_IN_A_MONTH` — `30.44` (Float), the average number of days in
70
+ a month.
71
+ - `Cs133::Averages::HOURS_IN_A_WEEK` — `168` (Integer), the number of hours in a week.
60
72
 
61
73
  ## How to use it
62
74
 
@@ -88,14 +100,24 @@ can pin "now" in tests.
88
100
  this_period = Order.where(created_at: current.to_range).sum(:total)
89
101
  ```
90
102
 
91
- 4. **Measure the prior period over `previous`.** It is guaranteed to be the same
103
+ 4. **For a week-by-week series, ask for the whole run at once.** `last_weeks` returns
104
+ every week already built, so measure each one with the same query and stop here —
105
+ steps 5 through 7 are the one-window-against-its-prior-period path:
106
+
107
+ ```ruby
108
+ weekly_totals = Cs133::Range.last_weeks(12, zone: zone).map do |week|
109
+ Order.where(created_at: week.to_range).sum(:total)
110
+ end
111
+ ```
112
+
113
+ 5. **Measure the prior period over `previous`.** It is guaranteed to be the same
92
114
  length, so the two numbers are comparable:
93
115
 
94
116
  ```ruby
95
117
  last_period = Order.where(created_at: current.previous.to_range).sum(:total)
96
118
  ```
97
119
 
98
- 5. **Compare the two numbers.** `Comparison` takes the measurements, never the ranges:
120
+ 6. **Compare the two numbers.** `Comparison` takes the measurements, never the ranges:
99
121
 
100
122
  ```ruby
101
123
  comparison = Cs133::Comparison.new(current: this_period, previous: last_period)
@@ -105,9 +127,21 @@ can pin "now" in tests.
105
127
  comparison.direction # => :up
106
128
  ```
107
129
 
108
- 6. **Render defensively.** Branch on `direction` for the arrow or color, and handle a
130
+ 7. **Render defensively.** Branch on `direction` for the arrow or color, and handle a
109
131
  `nil` `percent_change` with its own case (`"—"`, `"new"`) rather than formatting it.
110
132
 
133
+ 8. **To convert an amount between period lengths, use `Cs133::Averages`.** This is a
134
+ separate path from steps 2 through 7 and needs no zone. First ask the developer
135
+ whether the figure should describe an average month or one real month. An average
136
+ month uses the constants; one real month is measured over a `Range` from step 2
137
+ instead.
138
+
139
+ ```ruby
140
+ monthly = weekly_total * Cs133::Averages::WEEKS_IN_A_MONTH
141
+ daily = monthly_total / Cs133::Averages::DAYS_IN_A_MONTH
142
+ hourly = weekly_total / Cs133::Averages::HOURS_IN_A_WEEK.to_f
143
+ ```
144
+
111
145
  ## Conventions
112
146
 
113
147
  - Always pass an explicit `zone:`. Timezone correctness is the whole point; a range
@@ -122,6 +156,17 @@ can pin "now" in tests.
122
156
  is the last resort for a window nothing else expresses.
123
157
  - Get the prior window from `previous`, not by subtracting dates yourself — that is
124
158
  what keeps the two periods equal-length and the comparison honest.
159
+ - Use `previous` for one step back and no more. It subtracts the range's length in
160
+ seconds, so chaining it across a daylight saving change drifts an hour off the
161
+ calendar and stays off.
162
+ - Get a run of weeks from `last_weeks` rather than from repeated `previous` calls,
163
+ which is what keeps every week starting at midnight.
164
+ - Reference the `Cs133::Averages` constants by name rather than typing their values.
165
+ - Convert through one constant per step. `WEEKS_IN_A_MONTH * 7` is not
166
+ `DAYS_IN_A_MONTH`, so going from weeks to days through a month gives a different
167
+ number from multiplying by 7.
168
+ - `HOURS_IN_A_WEEK` is an Integer, so divide by it with a Float (`.to_f`) or an
169
+ integer amount loses its remainder.
125
170
  - Pass `now:` explicitly in tests to pin the clock; leave it out in production code.
126
171
  - Cs133 does not run queries, format numbers, or parse user input. Measuring and
127
172
  displaying stay in the consuming code.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: cs133-info
3
- description: Use to learn what Cs133 offers — timezone-aware time ranges, its presets, and period-over-period comparison.
3
+ description: Use to learn what Cs133 offers — timezone-aware time ranges, its presets, runs of whole weeks, period-over-period comparison, and calendar averages.
4
4
  tools: Read
5
5
  scope: timezone-aware time-range value objects, presets, and period-over-period comparison
6
6
  ---
@@ -10,18 +10,18 @@ changes, give no steps, and never read Cs133's source.
10
10
 
11
11
  ## What Cs133 is
12
12
 
13
- Cs133 is the middleman between a UI date filter and the queries it scopes. A
14
- stat's time range is an input that flows from the UI down to wherever the numbers
15
- originate, and the hard part of that input is getting period boundaries right in
16
- the account's timezone — where "this month" starts, where a day ends, what the
17
- immediately preceding window of the same span is. Cs133 owns exactly that and
18
- nothing else.
13
+ Cs133 turns a date filter into the time bounds a query needs. A stat's time range
14
+ is an input that flows from the UI down to wherever the numbers originate, and the
15
+ hard part of that input is getting period boundaries right in the account's
16
+ timezone — where "this month" starts, where a day ends, which week a date belongs
17
+ to, what the immediately preceding window of the same span is. Cs133 owns exactly
18
+ that and nothing else.
19
19
 
20
20
  Reach for it when an app filters or reports by period: dashboards, stat tiles,
21
- date pickers with presets, and anything that shows a number "vs. last period." It
22
- is pure Ruby value objects, with no knowledge of storage, metric, or UI layer — a
23
- range scopes a query the same way regardless of the app. If your time bounds are
24
- already correct and fixed, you don't need it.
21
+ date pickers with presets, week-by-week charts, and anything that shows a number
22
+ "vs. last period." It is pure Ruby value objects, with no knowledge of storage,
23
+ metric, or UI layer — a range scopes a query the same way regardless of the app.
24
+ If your time bounds are already correct and fixed, you don't need it.
25
25
 
26
26
  ## Interface
27
27
 
@@ -30,7 +30,8 @@ locals:
30
30
 
31
31
  - **`cs133-install`** owns getting the gem into a host and loaded.
32
32
  - **`cs133-develop`** owns everything you call — building ranges, the presets,
33
- the query-ready bounds, and comparison. It carries the exact signatures.
33
+ the query-ready bounds, comparison, and the calendar averages. It carries the
34
+ exact signatures.
34
35
 
35
36
  Do not reconstruct those calls from here; this local deliberately does not carry
36
37
  them.
@@ -51,19 +52,34 @@ time.
51
52
  - A **range** is an immutable value object holding an inclusive start and end
52
53
  time. Build a new one; never mutate.
53
54
  - A **preset** is a named window resolved against a **zone** and an anchor time —
54
- the current or previous calendar month, a trailing count of days, or the year
55
- so far. Presets are day-aligned: they snap to the beginning and end of the day
56
- in that zone.
55
+ the current or previous calendar month, a trailing count of days, the year so
56
+ far, or a run of recent weeks.
57
+ - Most presets are **day-aligned**: they snap to the beginning and end of the day
58
+ in that zone. The year-so-far preset is the exception and ends at the anchor
59
+ time itself, part-way through a day.
57
60
  - **`zone:`** is a timezone identifier string or an `ActiveSupport::TimeZone`, and
58
61
  it is always explicit. Ranges are timezone-aware on purpose; falling back to the
59
62
  server's local time is the bug Cs133 exists to prevent.
60
63
  - The anchor for "now" is injectable, so tests pin the current time rather than
61
64
  chasing it.
65
+ - A **week** runs Monday through Sunday in the zone. A run of weeks comes back as
66
+ several ranges rather than one, oldest first, with the last one holding the
67
+ anchor time.
62
68
  - **Length** is a span in seconds, and the **previous** window is the equal-span
63
69
  window immediately before a range — the basis of period-over-period.
70
+ - Stepping back by a span in seconds is not the same as stepping back by the
71
+ calendar. Across a daylight saving change the two land an hour apart, so a run
72
+ of calendar periods comes from the weeks preset rather than from taking the
73
+ previous window over and over.
64
74
  - A **comparison** is a separate value object over two already-measured numbers,
65
75
  a current and a previous, not over the ranges themselves. It reports the change
66
76
  between them: the raw difference, the proportional change, and whether it moved
67
77
  up, down, or stayed flat. You query each range first, then compare the results.
68
- - Bounds are validated on construction: an inverted range is an error, not a
69
- silently empty window.
78
+ - The proportional change is a fraction, not a percentage — a quarter more is
79
+ 0.25 — and there is none when the previous number is zero.
80
+ - The **calendar averages** are fixed numbers for converting a figure from one
81
+ period to another: about 4.33 weeks and 30.44 days in an average month, and 168
82
+ hours in a week. They describe no particular month, so they scale a rate, while
83
+ a range gives the real bounds of a real month.
84
+ - Building a range from two dates rejects an inverted pair with an error;
85
+ building one from explicit times validates nothing.
@@ -11,6 +11,7 @@ develop:
11
11
  - Cs133::Range.last_7_days
12
12
  - Cs133::Range.last_30_days
13
13
  - Cs133::Range.year_to_date
14
+ - Cs133::Range.last_weeks
14
15
  - Cs133::Range.new
15
16
  - Cs133::Range::InvalidBoundsError
16
17
  - range.start_time
@@ -24,7 +25,11 @@ develop:
24
25
  - comparison.delta
25
26
  - comparison.percent_change
26
27
  - comparison.direction
28
+ - Cs133::Averages::WEEKS_IN_A_MONTH
29
+ - Cs133::Averages::DAYS_IN_A_MONTH
30
+ - Cs133::Averages::HOURS_IN_A_WEEK
27
31
 
28
32
  sources:
29
33
  - lib/cs133/range.rb
30
34
  - lib/cs133/comparison.rb
35
+ - lib/cs133/averages.rb
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cs133
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - tylercschneider
@@ -44,6 +44,7 @@ files:
44
44
  - Rakefile
45
45
  - develop_process_rules.md
46
46
  - lib/cs133.rb
47
+ - lib/cs133/averages.rb
47
48
  - lib/cs133/comparison.rb
48
49
  - lib/cs133/range.rb
49
50
  - lib/cs133/reference.rb