Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
PlaceOS is a building automation platform.

## Working on this project

This crystal lang library is the database ORM layer, made up of models representing database tables. Functioning similar to ruby on rails active model.

Use `crystal tool format` and `./bin/ameba` to format and lint code. You can ignore ameba issues in `migration/lib/*`.
Run specs using `./test` make sure to run specs using a subagent. You can also run individual spec files or use `focus: true` to isolate the spec you're working on. Tests can take some time to run.

Note that if you have modified migrations, make sure to run `docker system prune --all` otherwise the specs will run against a cached database.
Put all your migrations into: ./migration/db/migrations/

A good example of a model is: ./src/placeos-models/module.cr

Good examples of how to implement junction tables: ./src/placeos-models/group/zone.cr

For the **new tables** in this project, please use `uuidv7` for primary keys:
`id UUID PRIMARY KEY DEFAULT uuidv7()`.

**Important:** existing tables (`user`, `authority`, `zone`, …) use `id TEXT PRIMARY KEY`
generated by `Utilities::IdGenerator`. Foreign keys from new tables to these existing
tables must therefore be `TEXT`, not `UUID`. Only FKs between two *new* tables are UUID.

Make sure to write thorough tests. Reference active_model in the lib folder if you want to see ORM internals. There are some extentions to the ORM in this project in src/placeos-models/base/* if you need to understand why something isn't working.

Make sure to create and maintain a plan file to keep track of progress.

## 1. Plan Node Default
- Enter plan mode for ANY non-trivial task (3+ steps or architectural decisions)
- If something goes sideways, STOP and re-plan immediately, don’t keep pushing
- Use plan mode for verification steps, not just building
- Write detailed specs upfront to reduce ambiguity

## 2. Subagent Strategy
- Use subagents liberally to keep main context window clean
- Offload research, exploration, and parallel analysis to subagents
- For complex problems, throw more compute at it via subagents
- One task per subagent for focused execution

## 3. Self-Improvement Loop
- After ANY correction from the user, update `tasks/lessons.md` with the pattern
- Write rules for yourself that prevent the same mistake
- Ruthlessly iterate on these lessons until mistake rate drops
- Review lessons at session start for relevant project

## 4. Verification Before Done
- Never mark a task complete without proving it works
- Diff behavior between main and your changes when relevant
- Ask yourself: "Would a staff engineer approve this?"
- Run tests, check logs, demonstrate correctness

## 5. Demand Elegance (Balanced)
- For non-trivial changes, pause and ask: "Is there a more elegant way?"
- If a fix feels hacky: "Knowing everything I know now, implement the elegant solution"
- Skip this for simple, obvious fixes, don’t over-engineer
- Challenge your own work before presenting it

## 6. Autonomous Bug Fixing
- When given a bug report, don’t ask for hand-holding
- Don’t start by trying to fix it. Instead, start by writing a test that reproduces the bug. Then, have subagents try to fix the bug and prove it by passing that test.
- Point at logs, errors, failing tests, then resolve them
- Zero context switching required from the user

---

## Task Management

1. **Plan First**: Write plan to `tasks/todo.md` with checkable items
2. **Verify Plan**: Check in before starting implementation
3. **Track Progress**: Mark items complete as you go
4. **Explain Changes**: High-level summary at each step
5. **Document Results**: Add review section to `tasks/todo.md`
6. **Capture Lessons**: Update `tasks/lessons.md` after corrections

---

## Core Principles

- **Simplicity First**: Make every change as simple as possible. Impact minimal code.
- **No Laziness**: Find root causes. No temporary fixes. Senior developer standards.
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
-- +micrate Up
-- SQL in section 'Up' is executed when this migration is applied

-- Add tags to playlist items (mirrors the tags column on zone)
ALTER TABLE "playlist_items" ADD COLUMN IF NOT EXISTS tags TEXT[] NOT NULL DEFAULT '{}'::TEXT[];

-- GIN index improves @> (contains), <@ (is contained by) and && (overlap) array lookups
CREATE INDEX IF NOT EXISTS playlist_items_tags_idx ON "playlist_items" USING GIN (tags);

-- Refactor playlist scheduling fields (scheduling not yet released, no data to preserve)
ALTER TABLE "playlists" DROP COLUMN IF EXISTS play_hours;
ALTER TABLE "playlists" DROP COLUMN IF EXISTS play_at_period;

-- play_cron becomes required with a sane default (midnight every day)
UPDATE "playlists" SET play_cron = '0 0 * * *' WHERE play_cron IS NULL;
ALTER TABLE "playlists" ALTER COLUMN play_cron SET DEFAULT '0 0 * * *';
ALTER TABLE "playlists" ALTER COLUMN play_cron SET NOT NULL;

-- how many minutes a scheduled playlist plays for (defaults to one day)
ALTER TABLE "playlists" ADD COLUMN IF NOT EXISTS play_period INTEGER NOT NULL DEFAULT 1440;

ALTER TABLE "playlists" RENAME COLUMN play_at_takeover TO play_takeover;

-- +micrate Down
-- SQL section 'Down' is executed when this migration is rolled back

ALTER TABLE "playlists" RENAME COLUMN play_takeover TO play_at_takeover;
ALTER TABLE "playlists" DROP COLUMN IF EXISTS play_period;
ALTER TABLE "playlists" ALTER COLUMN play_cron DROP NOT NULL;
ALTER TABLE "playlists" ALTER COLUMN play_cron DROP DEFAULT;
ALTER TABLE "playlists" ADD COLUMN IF NOT EXISTS play_at_period INTEGER;
ALTER TABLE "playlists" ADD COLUMN IF NOT EXISTS play_hours TEXT;

DROP INDEX IF EXISTS playlist_items_tags_idx;

ALTER TABLE "playlist_items" DROP COLUMN IF EXISTS tags;
21 changes: 21 additions & 0 deletions spec/playlist_item_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,27 @@ module PlaceOS::Model
item.save.should eq true
end

it "has unique tags" do
item = Generator.item
item.tags << "hello"
item.tags << "hello"
item.tags << "bye"
item.save!

Playlist::Item.find!(item.id.as(String)).tags.should eq Set{"hello", "bye"}
end

it ".with_tag" do
items = (0..5).map do |n|
item = Generator.item
item.tags = (0..n).map(&.to_s).to_set
item.save!
end

Playlist::Item.with_tag("0").to_a.compact_map(&.id).sort!.should eq items.compact_map(&.id).sort!
Playlist::Item.with_tag("3").to_a.compact_map(&.id).sort!.should eq items[3..].compact_map(&.id).sort!
end

it "cleans up playlists when an item is deleted" do
revision = Generator.revision

Expand Down
21 changes: 5 additions & 16 deletions spec/playlist_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -117,24 +117,13 @@ module PlaceOS::Model
})
end

it "validates play_hours are valid" do
it "defaults play_cron and play_period" do
playlist = Generator.playlist
playlist.play_hours = "25:00-16:00"
playlist.save.should eq false

playlist.play_hours = "10:75-16:00"
playlist.save.should eq false

playlist.play_hours = " 00:30-16:00"
playlist.save.should eq false

playlist.play_hours = "00:30 - 16:00"
playlist.save.should eq false

playlist.errors.first.field.should eq :play_hours

playlist.play_hours = "10:30-16:00"
playlist.save.should eq true

playlist = Playlist.find!(playlist.id.as(String))
playlist.play_cron.should eq "0 0 * * *"
playlist.play_period.should eq 1440
end

it "validates CRONs are valid" do
Expand Down
55 changes: 7 additions & 48 deletions src/placeos-models/playlist.cr
Original file line number Diff line number Diff line change
Expand Up @@ -47,19 +47,16 @@ module PlaceOS::Model
attribute valid_from : Int64? = nil
attribute valid_until : Int64? = nil

# hours in the timezone that the playlist should play
attribute play_hours : String? = nil

# start playing the playlist at exactly this time or on CRON schedule
# play_at will ignore timezones
attribute play_at : Int64? = nil
attribute play_cron : String? = nil
attribute play_cron : String = "0 0 * * *" # midnight every day

# how many minutes should a scheduled playlist play for / should it takeover the displays
attribute play_at_period : Int32? = nil
attribute play_at_takeover : Bool = false
attribute play_period : Int32 = 1440 # 1 day in minutes
attribute play_takeover : Bool = false

def should_present?(now : Time = Time.utc, timezone : Bool = false) : Bool
def should_present?(now : Time = Time.utc) : Bool
return false unless enabled

now_unix = now.to_unix
Expand All @@ -68,40 +65,9 @@ module PlaceOS::Model
return false if starting && starting > now_unix
return false if ending && ending <= now_unix

if timezone && (hours = play_hours.presence)
return between?(hours, now)
end

true
end

def between?(times : String, now : Time) : Bool
start_str, end_str = times.split('-', 2)

# Parse hours/minutes manually (faster + avoids timezone parsing issues)
start_hour = start_str[0, 2].to_i
start_min = start_str[3, 2].to_i
end_hour = end_str[0, 2].to_i
end_min = end_str[3, 2].to_i

now_minutes =
now.hour * 60 + now.minute

start_minutes =
start_hour * 60 + start_min

end_minutes =
end_hour * 60 + end_min

if start_minutes <= end_minutes
# Normal range (same day)
now_minutes >= start_minutes && now_minutes < end_minutes
else
# Overnight range (crosses midnight)
now_minutes >= start_minutes || now_minutes < end_minutes
end
end

def systems
ControlSystem.with_playlists({self.id.as(String)})
end
Expand Down Expand Up @@ -164,19 +130,12 @@ module PlaceOS::Model

validates :name, presence: true
validates :default_duration, presence: true, numericality: {greater_than: 999}

# valid format:
validates :play_hours, format: {
:message => "must be in the form: 10:30-16:00",
:with => /\A(?:[01]\d|2[0-3]):[0-5]\d-(?:[01]\d|2[0-3]):[0-5]\d\z/,
}
validates :play_cron, presence: true

# ensure crons valid
validate ->(this : Playlist) {
if (cron = this.play_cron.presence).nil?
this.play_cron = nil
return
end
cron = this.play_cron
return if cron.blank?

begin
CronParser.new(cron)
Expand Down
5 changes: 5 additions & 0 deletions src/placeos-models/playlist/item.cr
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ module PlaceOS::Model

attribute name : String, sanitize: :text, es_subfield: "keyword"
attribute description : String = "", sanitize: :common
attribute tags : Set(String) = -> { Set(String).new }, sanitize: :text

belongs_to Authority, foreign_key: "authority_id"

Expand Down Expand Up @@ -50,6 +51,10 @@ module PlaceOS::Model
Playlist::Item.where(id: item_ids).to_a
end

def self.with_tag(tag : String)
Playlist::Item.where("$1 = Any(tags)", tag)
end

def self.update_counts(metrics : Hash(String, Int32)) : Int64
return 0_i64 if metrics.empty?

Expand Down
Loading