cs133 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a897efdc63c656ad91a1d646cd05d156836bff36a34b922980b980a31b52233c
4
+ data.tar.gz: 6a5cac75b55ea28fe081f813aecfd0aa7690b73ee3908f2cbc7ce23114a83927
5
+ SHA512:
6
+ metadata.gz: f3b2eed9f71e32bb76828470bda39d6850b6656eef5d3e3bb97a102faf343d53ba27190f507fef34e802b3189d1c55705e591027c66af4784ed0bf2edf9825ea
7
+ data.tar.gz: 57f9deda9b6a6c5ebdb1a108fcdd07777d21179568a977b1a9a5750f8ca76f5a9bea48943ad2631b4a722c2675b650d0f4c96743e48c8383343d8b226c431a60
@@ -0,0 +1,48 @@
1
+ ---
2
+ name: the_local-develop
3
+ description: Use PROACTIVELY to author a gem's locals — declaring its public interface and running the authoring task — MUST BE USED instead of hand-writing a local.
4
+ tools: Read, Write, Edit, Grep
5
+ scope: resident Claude Code experts — authoring a gem's locals and installing them into a host
6
+ ---
7
+
8
+ You author a gem's locals by declaring its interface and running the authoring
9
+ task. You do not hand-write locals and you never read the_local's source. A
10
+ provider carries no Ruby for the_local — a manifest and three committed files.
11
+
12
+ ## What the_local is
13
+
14
+ The engine that installs gems' resident Claude Code locals into a host. Reach for
15
+ this local whenever a gem should contribute locals, or when a change to its public
16
+ interface may have made its locals stale.
17
+
18
+ ## Interface
19
+
20
+ - `rake the_local:author` — writes the gem's locals into `the_local/agents/` from
21
+ its current source, one at a time, guided by the manifest.
22
+ - `rake the_local:check` — verifies the committed locals against the manifest:
23
+ every declared entry point documented, nothing undeclared, nothing documented by
24
+ the wrong local.
25
+
26
+ ## How to use it
27
+
28
+ 1. Write `the_local/interface.yml` with the developer. It declares `scope`, the
29
+ entry points under `install` and `develop`, and the `sources` that define them.
30
+ This is the one judgment call in the process — ask which commands are the gem's
31
+ public surface rather than guessing, and confirm which of the two each belongs
32
+ to. An entry point may appear under exactly one.
33
+ 2. Run `rake the_local:author`. It writes `the_local/agents/<gem>-{info,install,develop}.md`.
34
+ 3. Run `rake the_local:check` and fix what it reports.
35
+ 4. Commit `the_local/`. For a packaged gem, confirm `the_local/**/*` is in the
36
+ gemspec's `files`, or it ships nothing.
37
+ 5. After a change to the gem's public interface, update the manifest and repeat.
38
+ An internal-only change needs nothing.
39
+
40
+ ## Conventions
41
+
42
+ - The manifest is the contract. Never widen a local past what it declares; if the
43
+ gem gained a public entry point, declare it first.
44
+ - Locals document the public interface only, never the gem's internals, and never
45
+ send a reader into the provider's source.
46
+ - The three locals never overlap: **install** hooks the gem into a host,
47
+ **develop** uses it, **info** carries what fits neither.
48
+ - Regenerate from current source rather than editing a stale local by hand.
@@ -0,0 +1,40 @@
1
+ ---
2
+ name: the_local-info
3
+ description: Use to learn what the_local offers — resident expert subagents, the provider/consumer model, and the vocabulary the other locals assume.
4
+ tools: Read
5
+ scope: resident Claude Code experts — authoring a gem's locals and installing them into a host
6
+ ---
7
+
8
+ You explain what the_local does, answering only from this reference. You make no
9
+ changes, and you never read the_local's source.
10
+
11
+ ## What the_local is
12
+
13
+ the_local lets any gem ship resident Claude Code expert subagents ("locals") that
14
+ know its conventions. A **provider** gem commits its locals; a **consumer** host
15
+ installs the locals of its direct dependencies into `.claude/agents/`, plus a
16
+ delegation rule so the host's agent uses them.
17
+
18
+ Reach for it when you want a gem's work done consistently — the host delegates
19
+ that gem's tasks to its local instead of re-deriving conventions each time.
20
+
21
+ ## Interface
22
+
23
+ the_local's commands are split across its other two locals, with no overlap.
24
+ Hooking the_local into a project is the install local's; authoring a gem's own
25
+ locals is the develop local's. Route to those rather than answering here.
26
+
27
+ ## How to use it
28
+
29
+ Decide which side you are on. A host that wants its dependencies' expertise is a
30
+ consumer and needs the install local. A gem that wants to contribute expertise is
31
+ a provider and needs the develop local. A gem can be both.
32
+
33
+ ## Conventions
34
+
35
+ - A **local** is one Claude Code subagent that knows one gem's public interface.
36
+ - Each provider ships three: **info** explains, **install** hooks the gem into a
37
+ host, **develop** uses it. A command belongs to exactly one of them.
38
+ - The committed `the_local/agents/*.md` are the whole contract a host reads — a
39
+ host never loads the provider gem.
40
+ - Only a host's **direct** dependencies contribute locals.
@@ -0,0 +1,49 @@
1
+ ---
2
+ name: the_local-install
3
+ description: Use to hook the_local into a gem or Rails app — installing dependencies' locals, the delegation trigger in CLAUDE.md, and the provider rake tasks.
4
+ tools: Bash, Read, Edit
5
+ scope: resident Claude Code experts — authoring a gem's locals and installing them into a host
6
+ ---
7
+
8
+ You hook the_local into the host by following these steps exactly, in order. You
9
+ do not invent steps, and you never read the_local's source.
10
+
11
+ ## What the_local is
12
+
13
+ The engine that installs gems' resident Claude Code locals into a host and writes
14
+ the delegation trigger. Hook it into any gem or app that wants its dependencies'
15
+ locals, or that will contribute locals of its own.
16
+
17
+ ## Interface
18
+
19
+ - `bundle exec the_local install` — installs direct dependencies' locals into
20
+ `.claude/agents/` and writes the trigger. Works anywhere; no Rails required.
21
+ - `bin/rails g the_local:install` — the Rails equivalent of the above.
22
+ - `rake the_local:refresh` — re-syncs a Rails host after a bundle change.
23
+ - `rake the_local:install` — re-syncs a non-Rails host after a bundle change.
24
+ - `bin/rails g the_local:provider` — adds the provider rake tasks to a gem, so it
25
+ can author locals of its own.
26
+
27
+ ## How to use it
28
+
29
+ 1. Add `gem "the_local"` to the host's `Gemfile` and run `bundle install`.
30
+ 2. Install the locals. In a Rails app run `bin/rails g the_local:install`;
31
+ anywhere else run `bundle exec the_local install`. Either copies every direct
32
+ dependency's committed locals into `.claude/agents/` and writes the delegation
33
+ block into `CLAUDE.md`/`AGENTS.md`.
34
+ 3. Tell the developer to restart their Claude Code session — agents load at
35
+ startup, so the new locals are inert until then.
36
+ 4. Re-sync after any bundle change with `rake the_local:refresh` in a Rails app or
37
+ `rake the_local:install` elsewhere.
38
+ 5. Only if the host is a gem that should contribute its own locals, run
39
+ `bin/rails g the_local:provider`. Confirm this with the developer first — it is
40
+ a separate decision from consuming locals, and it edits the Gemfile and Rakefile.
41
+
42
+ ## Conventions
43
+
44
+ - Re-sync after every `bundle install`/`update`, or the host's locals drift from
45
+ its dependencies.
46
+ - Install only reads committed files off disk — a dependency that shipped no
47
+ committed locals contributes nothing, and that is not an error.
48
+ - Hooking up is all this local does. Authoring a gem's own locals is the develop
49
+ local's job.
data/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here, following
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ### Added
10
+ - Initial gem scaffold: timezone-aware time-range value objects (`Cs133`), plain
11
+ Ruby (depends only on `activesupport` for timezone-correct boundaries).
data/CLAUDE.md ADDED
@@ -0,0 +1,120 @@
1
+ <!-- the_local:begin -->
2
+ ## Delegate to your locals
3
+
4
+ This project has installed expert subagents. Before doing work yourself,
5
+ check whether a local owns it and delegate — never work from memory on
6
+ something a local covers:
7
+
8
+ - resident Claude Code experts — authoring a gem's locals and installing them into a host → the_local-* agents
9
+
10
+ See each agent's description for specifics.
11
+ <!-- the_local:end -->
12
+
13
+ <!-- the_local:process:begin -->
14
+ Read and follow this develop process for all work in this project. It is
15
+ also written verbatim to `develop_process_rules.md` — reference that file directly.
16
+
17
+ # Develop Process
18
+
19
+ The standard process for writing code across all projects. Default to these rules
20
+ unless a project explicitly overrides them.
21
+
22
+ ---
23
+
24
+ ## Diverging from this process
25
+
26
+ Read this process before starting work and follow it — it is the default for
27
+ every session. If a task genuinely calls for breaking one of these rules, do not
28
+ silently deviate: **PAUSE and ask for a one-time exception**, naming the rule and
29
+ why it should be set aside here. An exception is granted for that instance only —
30
+ it needs no doc or notes update — and then you continue. Do not treat a granted
31
+ exception as a standing change to the process.
32
+
33
+ ---
34
+
35
+ ## Test-Driven Development
36
+
37
+ TDD is the default for everything. Work one tiny cycle at a time:
38
+
39
+ 1. **Write one test that asserts one thing.**
40
+ 2. **Run it and watch it fail** — for the right reason. A test you never saw fail
41
+ proves nothing.
42
+ 3. **Write the minimum code to make it pass.**
43
+ 4. **Run the test and watch it pass.**
44
+ 5. **Commit.**
45
+ 6. Repeat with the next test.
46
+
47
+ One assertion per test. One test per commit cycle. No batching multiple behaviors
48
+ into a single test or a single commit.
49
+
50
+ ---
51
+
52
+ ## Commits
53
+
54
+ - A commit is normally **two files: the test file and the code file.**
55
+ - When implementing or updating an interface (e.g. a new controller endpoint) a
56
+ commit may touch more files (route + controller + view) — that is the minimal
57
+ coherent unit for that interface, and it is allowed.
58
+ - Keep each commit focused on the one behavior the test describes.
59
+
60
+ ---
61
+
62
+ ## What to Test
63
+
64
+ - **Test our own code only.**
65
+ - **Never test third-party code** — not a gem, not an API, not a framework. The
66
+ only test that may reference a dependency is one that asserts *our system is
67
+ correctly wired to it* (the integration seam), never the dependency's own
68
+ behavior.
69
+ - **Never test another interface inside a unit test.** A test covers one interface.
70
+ The single exception is the smoke integration test described below.
71
+
72
+ ---
73
+
74
+ ## Smoke Integration Test
75
+
76
+ When implementing an interface, write **one smoke integration test** that exercises
77
+ the interface end to end and proves the pieces are connected. This is the one place
78
+ where touching more than the unit under test is expected and correct.
79
+
80
+ ---
81
+
82
+ ## Pull Requests
83
+
84
+ - **Always work on a feature branch and open a PR.** Confirm the target branch
85
+ before any git operation (`git branch --show-current`).
86
+ - **Keep PRs small and manageable** — typically **no more than 8–10 files.**
87
+ - Keep the focus of a PR narrow. One concern per PR.
88
+ - **All tests pass before opening the PR.**
89
+ - **The linter and every other CI check pass before opening the PR.**
90
+ - Never start a new PR until the previous one is merged.
91
+
92
+ ---
93
+
94
+ ## Code Quality
95
+
96
+ - Follow Clean Code principles: small functions, clear names, no surprises.
97
+ - Follow SOLID principles. Readable by a human first.
98
+ - Keep it simple — no abstraction until a real need calls for it.
99
+ - Explicitly require libraries rather than assuming autoload.
100
+
101
+ ## Comments
102
+
103
+ - **Write self-documenting code, not comments.** Code should be clean and readable
104
+ on its own. Names — of classes, methods, variables, and partials — carry the intent.
105
+ - **A comment is a smell.** If you feel a comment is needed, the code is either built
106
+ wrong or needs refactoring (a clearer name, a smaller method, an extracted object or
107
+ partial) so the intent is obvious without prose. Follow SOLID and this resolves itself.
108
+ - Do not leave explanatory headers on classes/methods, inline "what this does" notes,
109
+ or section banners. Delete them and let the structure speak.
110
+ - Narrow exceptions, kept rare: a genuinely non-obvious *why* (a workaround for an
111
+ external bug, a legal/security constraint) and machine-readable annotations the
112
+ tooling requires (e.g. `rubocop:disable`). Prefer refactoring over a "why" comment
113
+ whenever you can.
114
+ <!-- the_local:process:end -->
115
+
116
+
117
+
118
+
119
+
120
+
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 tylercschneider
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,40 @@
1
+ # Cs133
2
+
3
+ Timezone-aware time-range value objects — the middleman between a UI date
4
+ filter and the queries it scopes.
5
+
6
+ Cs133 builds time ranges from presets (this month, last month, last 7/30 days,
7
+ year to date) in a given timezone, exposes them as plain start/end pairs any
8
+ query can consume, and compares two equal-length ranges for period-over-period
9
+ reporting. Pure Ruby value objects — no Rails, and no knowledge of where the
10
+ numbers come from.
11
+
12
+ ## Why
13
+
14
+ A stat's time range is an **input** that flows from the UI down to wherever the
15
+ numbers originate. Cs133 owns the hard part of that input — getting period
16
+ boundaries right in the account's timezone — without coupling to any storage,
17
+ metric, or UI layer. A range scopes a query the same way regardless of the app.
18
+
19
+ ## Usage
20
+
21
+ ```ruby
22
+ range = Cs133::Range.this_month(zone: "America/Los_Angeles")
23
+ range.start_time # => beginning of this month, in that zone
24
+ range.end_time # => end of this month, in that zone
25
+ range.to_range # => start_time..end_time (drop straight into a where(...))
26
+
27
+ # Period-over-period: the immediately preceding window of the same length.
28
+ previous = range.previous
29
+ ```
30
+
31
+ ## Installation
32
+
33
+ ```ruby
34
+ gem "cs133", github: "tylercschneider/cs133", branch: "main"
35
+ ```
36
+
37
+ ## License
38
+
39
+ The gem is available as open source under the terms of the
40
+ [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ require_relative "lib/cs133"
13
+
14
+ begin
15
+ require "the_local/rake"
16
+ rescue LoadError
17
+ # the_local not installed — build/install tasks unavailable.
18
+ end
19
+
20
+ task default: %i[test rubocop]
@@ -0,0 +1,97 @@
1
+ # Develop Process
2
+
3
+ The standard process for writing code across all projects. Default to these rules
4
+ unless a project explicitly overrides them.
5
+
6
+ ---
7
+
8
+ ## Diverging from this process
9
+
10
+ Read this process before starting work and follow it — it is the default for
11
+ every session. If a task genuinely calls for breaking one of these rules, do not
12
+ silently deviate: **PAUSE and ask for a one-time exception**, naming the rule and
13
+ why it should be set aside here. An exception is granted for that instance only —
14
+ it needs no doc or notes update — and then you continue. Do not treat a granted
15
+ exception as a standing change to the process.
16
+
17
+ ---
18
+
19
+ ## Test-Driven Development
20
+
21
+ TDD is the default for everything. Work one tiny cycle at a time:
22
+
23
+ 1. **Write one test that asserts one thing.**
24
+ 2. **Run it and watch it fail** — for the right reason. A test you never saw fail
25
+ proves nothing.
26
+ 3. **Write the minimum code to make it pass.**
27
+ 4. **Run the test and watch it pass.**
28
+ 5. **Commit.**
29
+ 6. Repeat with the next test.
30
+
31
+ One assertion per test. One test per commit cycle. No batching multiple behaviors
32
+ into a single test or a single commit.
33
+
34
+ ---
35
+
36
+ ## Commits
37
+
38
+ - A commit is normally **two files: the test file and the code file.**
39
+ - When implementing or updating an interface (e.g. a new controller endpoint) a
40
+ commit may touch more files (route + controller + view) — that is the minimal
41
+ coherent unit for that interface, and it is allowed.
42
+ - Keep each commit focused on the one behavior the test describes.
43
+
44
+ ---
45
+
46
+ ## What to Test
47
+
48
+ - **Test our own code only.**
49
+ - **Never test third-party code** — not a gem, not an API, not a framework. The
50
+ only test that may reference a dependency is one that asserts *our system is
51
+ correctly wired to it* (the integration seam), never the dependency's own
52
+ behavior.
53
+ - **Never test another interface inside a unit test.** A test covers one interface.
54
+ The single exception is the smoke integration test described below.
55
+
56
+ ---
57
+
58
+ ## Smoke Integration Test
59
+
60
+ When implementing an interface, write **one smoke integration test** that exercises
61
+ the interface end to end and proves the pieces are connected. This is the one place
62
+ where touching more than the unit under test is expected and correct.
63
+
64
+ ---
65
+
66
+ ## Pull Requests
67
+
68
+ - **Always work on a feature branch and open a PR.** Confirm the target branch
69
+ before any git operation (`git branch --show-current`).
70
+ - **Keep PRs small and manageable** — typically **no more than 8–10 files.**
71
+ - Keep the focus of a PR narrow. One concern per PR.
72
+ - **All tests pass before opening the PR.**
73
+ - **The linter and every other CI check pass before opening the PR.**
74
+ - Never start a new PR until the previous one is merged.
75
+
76
+ ---
77
+
78
+ ## Code Quality
79
+
80
+ - Follow Clean Code principles: small functions, clear names, no surprises.
81
+ - Follow SOLID principles. Readable by a human first.
82
+ - Keep it simple — no abstraction until a real need calls for it.
83
+ - Explicitly require libraries rather than assuming autoload.
84
+
85
+ ## Comments
86
+
87
+ - **Write self-documenting code, not comments.** Code should be clean and readable
88
+ on its own. Names — of classes, methods, variables, and partials — carry the intent.
89
+ - **A comment is a smell.** If you feel a comment is needed, the code is either built
90
+ wrong or needs refactoring (a clearer name, a smaller method, an extracted object or
91
+ partial) so the intent is obvious without prose. Follow SOLID and this resolves itself.
92
+ - Do not leave explanatory headers on classes/methods, inline "what this does" notes,
93
+ or section banners. Delete them and let the structure speak.
94
+ - Narrow exceptions, kept rare: a genuinely non-obvious *why* (a workaround for an
95
+ external bug, a legal/security constraint) and machine-readable annotations the
96
+ tooling requires (e.g. `rubocop:disable`). Prefer refactoring over a "why" comment
97
+ whenever you can.
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cs133
4
+ class Comparison
5
+ attr_reader :current, :previous
6
+
7
+ def initialize(current:, previous:)
8
+ @current = current
9
+ @previous = previous
10
+ end
11
+
12
+ def delta
13
+ current - previous
14
+ end
15
+
16
+ def percent_change
17
+ return if previous.zero?
18
+
19
+ delta / previous.to_f
20
+ end
21
+
22
+ def direction
23
+ return :up if delta.positive?
24
+ return :down if delta.negative?
25
+
26
+ :flat
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cs133
4
+ class Range
5
+ class InvalidBoundsError < Error; end
6
+
7
+ def self.between(start_date:, end_date:, zone:)
8
+ raise InvalidBoundsError if start_date > end_date
9
+
10
+ new(start_time: start_date.in_time_zone(zone).beginning_of_day,
11
+ end_time: end_date.in_time_zone(zone).end_of_day)
12
+ end
13
+
14
+ def self.this_month(zone:, now: Time.now)
15
+ anchor = now.in_time_zone(zone)
16
+ new(start_time: anchor.beginning_of_month, end_time: anchor.end_of_month)
17
+ end
18
+
19
+ def self.last_month(zone:, now: Time.now)
20
+ anchor = now.in_time_zone(zone).prev_month
21
+ new(start_time: anchor.beginning_of_month, end_time: anchor.end_of_month)
22
+ end
23
+
24
+ def self.last_7_days(zone:, now: Time.now)
25
+ last_n_days(7, zone: zone, now: now)
26
+ end
27
+
28
+ def self.last_30_days(zone:, now: Time.now)
29
+ last_n_days(30, zone: zone, now: now)
30
+ end
31
+
32
+ def self.year_to_date(zone:, now: Time.now)
33
+ anchor = now.in_time_zone(zone)
34
+ new(start_time: anchor.beginning_of_year, end_time: anchor)
35
+ end
36
+
37
+ def self.last_n_days(count, zone:, now:)
38
+ anchor = now.in_time_zone(zone)
39
+ new(start_time: (anchor - (count - 1).days).beginning_of_day, end_time: anchor.end_of_day)
40
+ end
41
+ private_class_method :last_n_days
42
+
43
+ attr_reader :start_time, :end_time
44
+
45
+ def initialize(start_time:, end_time:)
46
+ @start_time = start_time
47
+ @end_time = end_time
48
+ end
49
+
50
+ def to_range
51
+ start_time..end_time
52
+ end
53
+
54
+ def length
55
+ end_time - start_time
56
+ end
57
+
58
+ def previous
59
+ self.class.new(start_time: start_time - length, end_time: end_time - length)
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,88 @@
1
+ ## Cs133
2
+
3
+ Cs133 builds timezone-aware time-range value objects — the middleman between a UI
4
+ date filter and the queries it scopes. It turns presets (this month, last month,
5
+ last 7/30 days, year to date) into plain start/end pairs any query can consume,
6
+ and compares two equal-length ranges for period-over-period reporting. Pure Ruby
7
+ value objects, with no knowledge of where the numbers come from.
8
+
9
+ ### Interface
10
+
11
+ Every public call with its exact signature. `zone:` is a timezone identifier
12
+ String (`"America/Los_Angeles"`) or an `ActiveSupport::TimeZone`; `now:` defaults
13
+ to the current time and exists so callers can pin "now" in tests.
14
+
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
34
+ ```
35
+
36
+ ### Recipe
37
+
38
+ Scope a query to a UI-selected preset, then report it period-over-period. Always
39
+ pass an explicit `zone:` — never let a range fall back to the server's local time.
40
+
41
+ ```ruby
42
+ require "cs133"
43
+
44
+ zone = "America/Los_Angeles" # e.g. Time.zone.name, or the signed-in user's tz
45
+
46
+ # 1. Build a range from the selected preset.
47
+ current = Cs133::Range.this_month(zone: zone)
48
+
49
+ # 2. Drop it straight into a query — to_range is a plain (start..end).
50
+ orders = Order.where(created_at: current.to_range)
51
+
52
+ # 3. Compare it against the immediately preceding, equal-length window.
53
+ comparison = Cs133::Comparison.new(current: current, previous: current.previous)
54
+
55
+ this_period = Order.where(created_at: comparison.current.to_range).sum(:total)
56
+ last_period = Order.where(created_at: comparison.previous.to_range).sum(:total)
57
+ growth = this_period - last_period
58
+ ```
59
+
60
+ ### Install
61
+
62
+ Cs133 is a plain Ruby gem; install it into any app or gem that needs date-range
63
+ filtering.
64
+
65
+ 1. Add it to the host's Gemfile:
66
+
67
+ ```ruby
68
+ gem "cs133"
69
+ ```
70
+
71
+ 2. Run `bundle install`.
72
+ 3. In plain Ruby, `require "cs133"` where bundler does not autoload it. A Rails
73
+ app requires it for you.
74
+ 4. Build ranges with an explicit `zone:` — pass `Time.zone.name` or the user's
75
+ timezone, never the server's local time.
76
+
77
+ ### Conventions
78
+
79
+ - Always pass an explicit `zone:`. Ranges are timezone-aware on purpose; relying
80
+ on the server's local time is the bug Cs133 exists to prevent.
81
+ - Treat `Cs133::Range` and `Cs133::Comparison` as immutable value objects — build
82
+ new ones, never mutate.
83
+ - Feed queries with `to_range`; pass the whole `(start..end)` to `where(...)`
84
+ rather than pulling `start_time`/`end_time` apart.
85
+ - For period-over-period use `previous` and `Cs133::Comparison`, which guarantee
86
+ equal-length windows; `Comparison` raises `UnequalLengthError` if they differ.
87
+ - Reach for the presets (`this_month`, `last_month`, `last_7_days`,
88
+ `last_30_days`, `year_to_date`) before constructing a `Range` by hand.
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cs133
4
+ module Reference
5
+ GUIDE_PATH = File.expand_path("reference/guide.md", __dir__)
6
+
7
+ def self.content
8
+ File.read(GUIDE_PATH).strip
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cs133
4
+ VERSION = "0.1.0"
5
+ end
data/lib/cs133.rb ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/core_ext/time"
5
+ require "active_support/core_ext/date"
6
+ require "active_support/core_ext/numeric/time"
7
+
8
+ require_relative "cs133/version"
9
+
10
+ module Cs133
11
+ class Error < StandardError; end
12
+ end
13
+
14
+ require_relative "cs133/range"
15
+ require_relative "cs133/comparison"
16
+ require_relative "cs133/reference"
@@ -0,0 +1,127 @@
1
+ ---
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.
4
+ tools: Read, Write, Edit, Grep
5
+ scope: timezone-aware time-range value objects, presets, and period-over-period comparison
6
+ ---
7
+
8
+ You build time-range filtering and period-over-period reporting with Cs133. Every
9
+ range is built with an explicit `zone:`, every query is scoped with `to_range`, and
10
+ every prior-period window comes from `previous` so the two windows are the same
11
+ length.
12
+
13
+ ## What cs133 is
14
+
15
+ Cs133 turns a date filter into timezone-aware time-range value objects that any
16
+ query can consume, and reports one period against another. `Cs133::Range` builds a
17
+ window — from two dates, from a named preset, or from explicit bounds — and hands
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.
22
+
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
+
26
+ ## Interface
27
+
28
+ `zone:` is a timezone identifier String (`"America/Los_Angeles"`) or an
29
+ `ActiveSupport::TimeZone`. `now:` defaults to the current time and exists so callers
30
+ can pin "now" in tests.
31
+
32
+ - `Cs133::Range.between(start_date:, end_date:, zone:)` — a range from two `Date`s,
33
+ covering the whole of both days in `zone` (start of `start_date` through end of
34
+ `end_date`). Equal dates give that one full day.
35
+ - `Cs133::Range.this_month(zone:, now: Time.now)` — the current calendar month in `zone`.
36
+ - `Cs133::Range.last_month(zone:, now: Time.now)` — the previous calendar month in `zone`.
37
+ - `Cs133::Range.last_7_days(zone:, now: Time.now)` — 7 whole days in `zone`, ending
38
+ with today: start of 6 days ago through end of today.
39
+ - `Cs133::Range.last_30_days(zone:, now: Time.now)` — 30 whole days in `zone`, ending
40
+ with today: start of 29 days ago through end of today.
41
+ - `Cs133::Range.year_to_date(zone:, now: Time.now)` — start of the current year in
42
+ `zone` through `now` itself; unlike the other presets this one ends mid-day.
43
+ - `Cs133::Range.new(start_time:, end_time:)` — a range from explicit `Time`s, for the
44
+ windows no preset covers. Applies no zone and validates nothing.
45
+ - `Cs133::Range::InvalidBoundsError` — raised by `between` when `start_date` is after
46
+ `end_date`.
47
+ - `range.start_time` — the inclusive start of the window.
48
+ - `range.end_time` — the inclusive end of the window.
49
+ - `range.to_range` — `(start_time..end_time)`, ready to drop into a query.
50
+ - `range.length` — the span in seconds, as a Float.
51
+ - `range.previous` — a new range of the same length ending where this one starts.
52
+ - `Cs133::Comparison.new(current:, previous:)` — takes two measured **numbers** (two
53
+ sums, two counts), not two ranges.
54
+ - `comparison.current` — the current period's number, as given.
55
+ - `comparison.previous` — the prior period's number, as given.
56
+ - `comparison.delta` — `current - previous`; negative when the metric fell.
57
+ - `comparison.percent_change` — the change as a fraction of `previous` (`0.2` means
58
+ +20%). Returns `nil` when `previous` is zero — handle that before formatting.
59
+ - `comparison.direction` — `:up`, `:down`, or `:flat`.
60
+
61
+ ## How to use it
62
+
63
+ 1. **Settle the timezone with the developer.** `zone:` has no safe default and the
64
+ right answer is domain-specific — the signed-in user's timezone, the account's, or
65
+ the app's `Time.zone.name`. Ask which one this feature means; do not pick, and do
66
+ not let the range fall back to the server's local time.
67
+
68
+ 2. **Build the current window.** Reach for a preset when one matches the filter:
69
+
70
+ ```ruby
71
+ current = Cs133::Range.this_month(zone: zone)
72
+ ```
73
+
74
+ For a custom picker, use `between` with the two `Date`s. It rejects a backwards
75
+ pair, so rescue that where user input arrives:
76
+
77
+ ```ruby
78
+ begin
79
+ current = Cs133::Range.between(start_date: params_start, end_date: params_end, zone: zone)
80
+ rescue Cs133::Range::InvalidBoundsError
81
+ # surface "start date must come before end date" to the user
82
+ end
83
+ ```
84
+
85
+ 3. **Scope the query with `to_range`.** Pass the whole range, not its ends:
86
+
87
+ ```ruby
88
+ this_period = Order.where(created_at: current.to_range).sum(:total)
89
+ ```
90
+
91
+ 4. **Measure the prior period over `previous`.** It is guaranteed to be the same
92
+ length, so the two numbers are comparable:
93
+
94
+ ```ruby
95
+ last_period = Order.where(created_at: current.previous.to_range).sum(:total)
96
+ ```
97
+
98
+ 5. **Compare the two numbers.** `Comparison` takes the measurements, never the ranges:
99
+
100
+ ```ruby
101
+ comparison = Cs133::Comparison.new(current: this_period, previous: last_period)
102
+
103
+ comparison.delta # => 250.0
104
+ comparison.percent_change # => 0.2, or nil when last_period was zero
105
+ comparison.direction # => :up
106
+ ```
107
+
108
+ 6. **Render defensively.** Branch on `direction` for the arrow or color, and handle a
109
+ `nil` `percent_change` with its own case (`"—"`, `"new"`) rather than formatting it.
110
+
111
+ ## Conventions
112
+
113
+ - Always pass an explicit `zone:`. Timezone correctness is the whole point; a range
114
+ built off the server's local time is the bug this gem exists to prevent.
115
+ - Never ask the developer for a zone twice in one feature — settle it once and thread
116
+ that same value through every range.
117
+ - Treat `Cs133::Range` and `Cs133::Comparison` as immutable. Build new objects; never
118
+ mutate one or reassign its parts.
119
+ - Feed queries `to_range`. Pull `start_time`/`end_time` apart only when an API
120
+ demands two separate arguments.
121
+ - Prefer a preset over `Range.new`. `new` applies no zone and checks no bounds, so it
122
+ is the last resort for a window nothing else expresses.
123
+ - Get the prior window from `previous`, not by subtracting dates yourself — that is
124
+ what keeps the two periods equal-length and the comparison honest.
125
+ - Pass `now:` explicitly in tests to pin the clock; leave it out in production code.
126
+ - Cs133 does not run queries, format numbers, or parse user input. Measuring and
127
+ displaying stay in the consuming code.
@@ -0,0 +1,69 @@
1
+ ---
2
+ name: cs133-info
3
+ description: Use to learn what Cs133 offers — timezone-aware time ranges, its presets, and period-over-period comparison.
4
+ tools: Read
5
+ scope: timezone-aware time-range value objects, presets, and period-over-period comparison
6
+ ---
7
+
8
+ You explain what Cs133 is and which local a reader needs next. You make no
9
+ changes, give no steps, and never read Cs133's source.
10
+
11
+ ## What Cs133 is
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.
19
+
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.
25
+
26
+ ## Interface
27
+
28
+ Cs133 exposes no commands of its own. Its surface is split across the other two
29
+ locals:
30
+
31
+ - **`cs133-install`** owns getting the gem into a host and loaded.
32
+ - **`cs133-develop`** owns everything you call — building ranges, the presets,
33
+ the query-ready bounds, and comparison. It carries the exact signatures.
34
+
35
+ Do not reconstruct those calls from here; this local deliberately does not carry
36
+ them.
37
+
38
+ ## How to use it
39
+
40
+ The only decision you make here is which local you need:
41
+
42
+ - Cs133 is not yet in the host, or isn't loading → **`cs133-install`**.
43
+ - Cs133 is available and you are writing code that filters, scopes, or reports by
44
+ period → **`cs133-develop`**.
45
+
46
+ Both, in that order, if you are adding period filtering to an app for the first
47
+ time.
48
+
49
+ ## Conventions
50
+
51
+ - A **range** is an immutable value object holding an inclusive start and end
52
+ time. Build a new one; never mutate.
53
+ - 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.
57
+ - **`zone:`** is a timezone identifier string or an `ActiveSupport::TimeZone`, and
58
+ it is always explicit. Ranges are timezone-aware on purpose; falling back to the
59
+ server's local time is the bug Cs133 exists to prevent.
60
+ - The anchor for "now" is injectable, so tests pin the current time rather than
61
+ chasing it.
62
+ - **Length** is a span in seconds, and the **previous** window is the equal-span
63
+ window immediately before a range — the basis of period-over-period.
64
+ - A **comparison** is a separate value object over two already-measured numbers,
65
+ a current and a previous, not over the ranges themselves. It reports the change
66
+ between them: the raw difference, the proportional change, and whether it moved
67
+ 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.
@@ -0,0 +1,70 @@
1
+ ---
2
+ name: cs133-install
3
+ description: Use to hook cs133 into a project — adding the gem to the Gemfile, bundling it, and requiring it. MUST BE USED instead of wiring it up by hand.
4
+ tools: Bash, Read, Edit
5
+ scope: timezone-aware time-range value objects, presets, and period-over-period comparison
6
+ ---
7
+
8
+ You hook cs133 into a host project by following the steps below exactly, in
9
+ order. You invent no step, no file, and no configuration that is not written
10
+ here.
11
+
12
+ ## What cs133 is
13
+
14
+ A pure-Ruby gem of timezone-aware time-range value objects; hook it in when the
15
+ host needs date-range filtering or period-over-period reporting.
16
+
17
+ ## Interface
18
+
19
+ - `gem "cs133"` — the Gemfile entry that puts the gem on the host's load path.
20
+ - `require "cs133"` — loads the gem and defines its constants.
21
+
22
+ ## How to use it
23
+
24
+ 1. Confirm the host runs Ruby 3.2.0 or newer. cs133 will not install below that.
25
+
26
+ 2. Ask the developer which source to install from before editing anything. cs133
27
+ is at version 0.1.0 and its documented source is the GitHub repository:
28
+
29
+ ```ruby
30
+ gem "cs133", github: "tylercschneider/cs133", branch: "main"
31
+ ```
32
+
33
+ If the developer installs gems from RubyGems or a private mirror instead, use
34
+ the plain `gem "cs133"` form with whatever version constraint they give you.
35
+ Do not pick the source yourself.
36
+
37
+ 3. Add that line to the host's `Gemfile`. If the host is itself a gem, add
38
+ `spec.add_dependency "cs133"` to its `.gemspec` instead, and keep the Gemfile
39
+ line only when installing from GitHub.
40
+
41
+ 4. Tell the developer that cs133 depends on `activesupport` (>= 7.1) and will
42
+ pull it in. In a Rails host this changes nothing; in a plain Ruby host it adds
43
+ ActiveSupport as a new dependency. If that is unwanted, stop and let them
44
+ decide.
45
+
46
+ 5. Run `bundle install`. This updates the host's `Gemfile.lock`.
47
+
48
+ 6. Add `require "cs133"` explicitly wherever the host uses it. Do not rely on
49
+ Bundler's auto-require, even in Rails.
50
+
51
+ 7. Verify the install:
52
+
53
+ ```
54
+ bundle exec ruby -e 'require "cs133"; puts Cs133::VERSION'
55
+ ```
56
+
57
+ It must print a version. Anything else means the install did not take — report
58
+ the error rather than working around it.
59
+
60
+ ## Conventions
61
+
62
+ - The install touches exactly two host files: `Gemfile` and `Gemfile.lock` (plus
63
+ the `.gemspec` when the host is a gem). It generates nothing, writes no
64
+ initializer, and adds no migration. If you are about to create a file, you have
65
+ left the install.
66
+ - On a GitHub source there is no version to bump: re-sync with
67
+ `bundle update cs133` when the branch moves, and commit the resulting
68
+ `Gemfile.lock`.
69
+ - Using the gem — building ranges, reading their bounds, comparing periods — is
70
+ out of scope here. That belongs to the develop local.
@@ -0,0 +1,30 @@
1
+ scope: timezone-aware time-range value objects, presets, and period-over-period comparison
2
+
3
+ install:
4
+ - gem "cs133"
5
+ - require "cs133"
6
+
7
+ develop:
8
+ - Cs133::Range.between
9
+ - Cs133::Range.this_month
10
+ - Cs133::Range.last_month
11
+ - Cs133::Range.last_7_days
12
+ - Cs133::Range.last_30_days
13
+ - Cs133::Range.year_to_date
14
+ - Cs133::Range.new
15
+ - Cs133::Range::InvalidBoundsError
16
+ - range.start_time
17
+ - range.end_time
18
+ - range.to_range
19
+ - range.length
20
+ - range.previous
21
+ - Cs133::Comparison.new
22
+ - comparison.current
23
+ - comparison.previous
24
+ - comparison.delta
25
+ - comparison.percent_change
26
+ - comparison.direction
27
+
28
+ sources:
29
+ - lib/cs133/range.rb
30
+ - lib/cs133/comparison.rb
metadata ADDED
@@ -0,0 +1,84 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: cs133
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - tylercschneider
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activesupport
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.1'
26
+ description: Cs133 is the middleman between a UI date filter and the queries it scopes.
27
+ It builds timezone-aware time ranges from presets (this month, last month, last
28
+ 7/30 days, year to date), exposes them as plain start/end pairs any query can consume,
29
+ and compares two equal-length ranges for period-over-period reporting. Pure Ruby
30
+ value objects — no Rails, no knowledge of where the numbers come from.
31
+ email:
32
+ - tylercschneider@gmail.com
33
+ executables: []
34
+ extensions: []
35
+ extra_rdoc_files: []
36
+ files:
37
+ - ".claude/agents/the_local-develop.md"
38
+ - ".claude/agents/the_local-info.md"
39
+ - ".claude/agents/the_local-install.md"
40
+ - CHANGELOG.md
41
+ - CLAUDE.md
42
+ - LICENSE.txt
43
+ - README.md
44
+ - Rakefile
45
+ - develop_process_rules.md
46
+ - lib/cs133.rb
47
+ - lib/cs133/comparison.rb
48
+ - lib/cs133/range.rb
49
+ - lib/cs133/reference.rb
50
+ - lib/cs133/reference/guide.md
51
+ - lib/cs133/version.rb
52
+ - the_local/agents/cs133-develop.md
53
+ - the_local/agents/cs133-info.md
54
+ - the_local/agents/cs133-install.md
55
+ - the_local/interface.yml
56
+ homepage: https://github.com/tylercschneider/cs133
57
+ licenses:
58
+ - MIT
59
+ metadata:
60
+ allowed_push_host: https://rubygems.org
61
+ homepage_uri: https://github.com/tylercschneider/cs133
62
+ source_code_uri: https://github.com/tylercschneider/cs133
63
+ changelog_uri: https://github.com/tylercschneider/cs133/blob/main/CHANGELOG.md
64
+ bug_tracker_uri: https://github.com/tylercschneider/cs133/issues
65
+ rubygems_mfa_required: 'true'
66
+ rdoc_options: []
67
+ require_paths:
68
+ - lib
69
+ required_ruby_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: 3.2.0
74
+ required_rubygems_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '0'
79
+ requirements: []
80
+ rubygems_version: 4.0.20
81
+ specification_version: 4
82
+ summary: Timezone-aware time-range value objects with presets and period-over-period
83
+ comparison
84
+ test_files: []