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 +7 -0
- data/.claude/agents/the_local-develop.md +48 -0
- data/.claude/agents/the_local-info.md +40 -0
- data/.claude/agents/the_local-install.md +49 -0
- data/CHANGELOG.md +11 -0
- data/CLAUDE.md +120 -0
- data/LICENSE.txt +21 -0
- data/README.md +40 -0
- data/Rakefile +20 -0
- data/develop_process_rules.md +97 -0
- data/lib/cs133/comparison.rb +29 -0
- data/lib/cs133/range.rb +62 -0
- data/lib/cs133/reference/guide.md +88 -0
- data/lib/cs133/reference.rb +11 -0
- data/lib/cs133/version.rb +5 -0
- data/lib/cs133.rb +16 -0
- data/the_local/agents/cs133-develop.md +127 -0
- data/the_local/agents/cs133-info.md +69 -0
- data/the_local/agents/cs133-install.md +70 -0
- data/the_local/interface.yml +30 -0
- metadata +84 -0
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
|
data/lib/cs133/range.rb
ADDED
|
@@ -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.
|
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: []
|