coach_zed 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 42078dac83da8db61434faa687eb655e700e4d7602227fe7868c57428dfcb759
4
- data.tar.gz: d160f2894666ae27bcebbcb095e73a85213c8f9c3f06ce4a7f0ab0b0745abfdc
3
+ metadata.gz: 4e97024f8b6766bd4d08c6811960bbd5d75e10ca7cbeb4032fe4167e6b1f823f
4
+ data.tar.gz: b0861f0e335a273a49703b1bcd17be48697ec1008c3bf2541a809e6ddb09aa02
5
5
  SHA512:
6
- metadata.gz: a4c29470d8c22cb1cb0627b73688e9460101c3a31445c91f1613cbd29c44e654ecb9d42446012afc7a9fb244e14f5f451b48f86c5ae0bf54eb38e07df3563961
7
- data.tar.gz: bb54afff27dd32108cb527a5f50d949709037013078733af6f0e930ad39c21e64cb26666ae2a3a62549b104a127121575ebb63e77e98259a99f375a5ebf1d214
6
+ metadata.gz: 5ea3e7f3d8fea32340b42179590ab8e7c607d7a3e1daba4a2735246a54443b5519066c6a9bf20de1eed66745ac6279b8b7d32dfeab2edbb9984368cc810f94ea
7
+ data.tar.gz: e1fd57f9709a06e7a1975fab46f62e28c31998ec3834882a09a2da81578b7daced1baf9e72fd14fed26b12e1b07a3fff86fbbe5a8ee3f0daad104bcf68a9b108
data/CHANGELOG.md CHANGED
@@ -1,3 +1,12 @@
1
+ ## [](https://github.com/rossta/coach_zed/compare/v0.7.0...v) (2026-06-28)
2
+
3
+ ### Features
4
+
5
+ * merge schedules in json ([b8343be](https://github.com/rossta/coach_zed/commit/b8343bee863437f9313e6e0d4782aaa05a199fe5))
6
+
7
+ ### Bug Fixes
8
+
9
+ * dedupe overlapping appended feed events ([d19060e](https://github.com/rossta/coach_zed/commit/d19060ed42ee960e73fdebf151c3195978d44b51))
1
10
  ## [](https://github.com/rossta/coach_zed/compare/v0.6.0...v) (2026-06-18)
2
11
 
3
12
  ### Features
@@ -5,24 +5,18 @@ require "time"
5
5
 
6
6
  class CoachZed
7
7
  class FeedWriter
8
- def initialize(schedule:, start_date:, existing_feed_content: nil, calendar_name: nil)
8
+ def initialize(schedule:, calendar_name: nil)
9
9
  @schedule = schedule
10
- @start_date = start_date
11
- @existing_feed_content = existing_feed_content
12
10
  @calendar_name = calendar_name
13
11
  end
14
12
 
15
13
  def build
16
- if existing_feed_content
17
- append_to_existing_feed(existing_feed_content)
18
- else
19
- fresh_feed
20
- end
14
+ fresh_feed
21
15
  end
22
16
 
23
17
  private
24
18
 
25
- attr_reader :schedule, :start_date, :existing_feed_content, :calendar_name
19
+ attr_reader :schedule, :calendar_name
26
20
 
27
21
  def fresh_feed
28
22
  lines = header_lines
@@ -31,11 +25,6 @@ class CoachZed
31
25
  lines.join("\r\n") + "\r\n"
32
26
  end
33
27
 
34
- def append_to_existing_feed(existing_feed)
35
- event_block = event_lines.join("\r\n") + "\r\n"
36
- existing_feed.sub(/END:VCALENDAR\s*\z/, "#{event_block}END:VCALENDAR\r\n")
37
- end
38
-
39
28
  def header_lines
40
29
  [
41
30
  "BEGIN:VCALENDAR",
@@ -50,10 +39,10 @@ class CoachZed
50
39
 
51
40
  def event_lines
52
41
  schedule.fetch("days").flat_map do |day|
53
- date = start_date + (day.fetch("day_number").to_i - 1)
42
+ date = Date.strptime(day.fetch("date"), "%Y-%m-%d")
54
43
  [
55
44
  "BEGIN:VEVENT",
56
- "UID:#{schedule.fetch("schedule_id")}-#{format("%02d", day.fetch("day_number").to_i)}@coach_zed",
45
+ "UID:#{date.strftime("%Y%m%d")}@coach_zed",
57
46
  "DTSTAMP:#{generated_timestamp}",
58
47
  "DTSTART;VALUE=DATE:#{date.strftime("%Y%m%d")}",
59
48
  "DTEND;VALUE=DATE:#{(date + 1).strftime("%Y%m%d")}",
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class CoachZed
4
- VERSION = "0.7.0"
4
+ VERSION = "0.8.0"
5
5
  end
data/lib/coach_zed.rb CHANGED
@@ -4,6 +4,7 @@ require "date"
4
4
  require "digest"
5
5
  require "json"
6
6
  require "pathname"
7
+ require "time"
7
8
  require "yaml"
8
9
 
9
10
  require_relative "coach_zed/version"
@@ -19,7 +20,7 @@ class CoachZed
19
20
  Result = Data.define(:schedule_path, :ics_path, :webcal_path, :schedule)
20
21
 
21
22
  class Config
22
- attr_accessor :workout_catalog_dir, :model, :output_dir, :feed_output_basename, :feed_title, :existing_feed_path
23
+ attr_accessor :workout_catalog_dir, :model, :output_dir, :feed_output_basename, :feed_title, :existing_feed_path, :existing_schedule_path, :merge_policy
23
24
 
24
25
  def initialize(
25
26
  workout_catalog_dir: nil,
@@ -27,7 +28,9 @@ class CoachZed
27
28
  output_dir: nil,
28
29
  feed_output_basename: nil,
29
30
  feed_title: nil,
30
- existing_feed_path: nil
31
+ existing_feed_path: nil,
32
+ existing_schedule_path: nil,
33
+ merge_policy: nil
31
34
  )
32
35
  @workout_catalog_dir = workout_catalog_dir
33
36
  @model = model
@@ -35,6 +38,8 @@ class CoachZed
35
38
  @feed_output_basename = feed_output_basename
36
39
  @feed_title = feed_title
37
40
  @existing_feed_path = existing_feed_path
41
+ @existing_schedule_path = existing_schedule_path
42
+ @merge_policy = merge_policy
38
43
  end
39
44
 
40
45
  def apply(hash)
@@ -100,7 +105,9 @@ class CoachZed
100
105
  output_dir: nil,
101
106
  feed_output_basename: nil,
102
107
  feed_title: nil,
103
- existing_feed_path: nil
108
+ existing_feed_path: nil,
109
+ existing_schedule_path: nil,
110
+ merge_policy: nil
104
111
  )
105
112
  config = self.class.config
106
113
 
@@ -114,31 +121,43 @@ class CoachZed
114
121
  @feed_title = feed_title.nil? ? config.feed_title : feed_title
115
122
  resolved_existing_feed_path = existing_feed_path.nil? ? config.existing_feed_path : existing_feed_path
116
123
  @existing_feed_path = resolved_existing_feed_path && Pathname(resolved_existing_feed_path)
124
+ resolved_existing_schedule_path = existing_schedule_path.nil? ? config.existing_schedule_path : existing_schedule_path
125
+ resolved_existing_schedule_path =
126
+ if resolved_existing_schedule_path.nil? && @existing_feed_path
127
+ Pathname(@existing_feed_path.to_s.sub(/\.ics\z/, ".json"))
128
+ else
129
+ resolved_existing_schedule_path
130
+ end
131
+ @existing_schedule_path = resolved_existing_schedule_path && Pathname(resolved_existing_schedule_path)
132
+ @merge_policy = merge_policy.nil? ? config.merge_policy : merge_policy
117
133
  end
118
134
 
119
- def generate_schedule(start_date:, consultation_prompt: nil, consultation_prompt_path: nil, generation_mode: nil)
135
+ def generate_schedule(start_date:, consultation_prompt: nil, consultation_prompt_path: nil, generation_mode: nil, merge_policy: nil)
120
136
  prompt_text = resolve_prompt_text(consultation_prompt, consultation_prompt_path)
121
137
  catalog = Catalog::Loader.new(@workout_catalog_dir).load
122
138
  generation_mode = normalize_generation_mode(generation_mode)
123
- existing_feed = load_existing_feed if generation_mode != :refresh
124
- start_date = generation_start_date(start_date, existing_feed:, generation_mode:)
125
- generation_days = generation_days_for(start_date, generation_mode:, existing_feed:)
126
- existing_feed_context = existing_feed&.to_context(limit_days: 28)
127
- schedule_key = schedule_key_for(prompt_text, start_date, catalog, generation_days, existing_feed_context)
139
+ merge_policy = normalize_merge_policy(merge_policy || @merge_policy || generation_mode)
140
+ existing_schedule = load_existing_schedule if merge_policy == :append
141
+ existing_feed = load_existing_feed if existing_schedule.nil? && generation_mode != :refresh
142
+ start_date = generation_start_date(start_date, existing_schedule:, generation_mode:, merge_policy:)
143
+ generation_days = generation_days_for(start_date, generation_mode:, existing_schedule:, merge_policy:)
144
+ existing_context = existing_schedule ? schedule_context(existing_schedule, limit_days: 28) : existing_feed&.to_context(limit_days: 28)
145
+ schedule_key = schedule_key_for(prompt_text, start_date, catalog, generation_days, existing_context, merge_policy)
128
146
  prompt = PromptBuilder.new(
129
147
  consultation_prompt: prompt_text,
130
148
  catalog: catalog,
131
149
  start_date: start_date,
132
150
  schedule_key: schedule_key,
133
151
  generation_days: generation_days,
134
- existing_feed_context: existing_feed_context
152
+ existing_feed_context: existing_context
135
153
  ).build
136
154
  raw_schedule = @ai_client.generate(prompt:)
137
155
  schedule = ScheduleParser.parse(raw_schedule)
138
- schedule = normalize_schedule(schedule, start_date:, prompt_text:, schedule_key:, catalog:, generation_days:)
156
+ schedule = normalize_schedule(schedule, start_date:, prompt_text:, schedule_key:, catalog:, generation_days:, merge_policy:)
157
+ schedule = merge_schedule(existing_schedule, schedule, merge_policy)
139
158
 
140
159
  schedule_path = write_schedule(schedule, schedule_key)
141
- feed_paths = write_feeds(schedule, start_date:, existing_feed:)
160
+ feed_paths = write_feeds(schedule)
142
161
 
143
162
  Result.new(
144
163
  schedule_path: schedule_path,
@@ -150,7 +169,7 @@ class CoachZed
150
169
 
151
170
  private
152
171
 
153
- attr_reader :workout_catalog_dir, :ai_client, :output_dir, :schedule_output_dir, :feed_output_dir, :feed_output_basename, :feed_title, :existing_feed_path
172
+ attr_reader :workout_catalog_dir, :ai_client, :output_dir, :schedule_output_dir, :feed_output_dir, :feed_output_basename, :feed_title, :existing_feed_path, :existing_schedule_path, :merge_policy
154
173
 
155
174
  def wrap_client(client, model:)
156
175
  return client if client.is_a?(Clients::RubyOpenAI)
@@ -195,6 +214,15 @@ class CoachZed
195
214
  FeedReader.load_existing(existing_feed_path)
196
215
  end
197
216
 
217
+ def load_existing_schedule
218
+ return nil if existing_schedule_path.nil?
219
+ return nil unless existing_schedule_path.exist?
220
+
221
+ schedule = JSON.parse(existing_schedule_path.read)
222
+ ScheduleParser.validate!(schedule)
223
+ schedule
224
+ end
225
+
198
226
  def normalize_generation_mode(value)
199
227
  return nil if value.nil?
200
228
 
@@ -206,33 +234,48 @@ class CoachZed
206
234
  end
207
235
  end
208
236
 
209
- def generation_start_date(start_date, existing_feed:, generation_mode:)
237
+ def normalize_merge_policy(value)
238
+ return :replace if value.nil?
239
+
240
+ case value.to_sym
241
+ when :replace, :append
242
+ value.to_sym
243
+ else
244
+ raise ArgumentError, "unsupported merge policy: #{value}"
245
+ end
246
+ end
247
+
248
+ def generation_start_date(start_date, existing_schedule:, generation_mode:, merge_policy:)
210
249
  return normalize_date(start_date) if generation_mode == :refresh
211
250
 
212
- last_date = existing_feed&.last_date
213
- return normalize_date(start_date) if last_date.nil?
251
+ if merge_policy == :append
252
+ last_date = existing_schedule&.fetch("days")&.map { |day| Date.parse(day.fetch("date")) }&.max
253
+ return normalize_date(start_date) if last_date.nil?
214
254
 
215
- last_date + 1
255
+ last_date + 1
256
+ else
257
+ normalize_date(start_date)
258
+ end
216
259
  end
217
260
 
218
- def generation_days_for(start_date, generation_mode:, existing_feed:)
219
- return 7 if generation_mode == :append && existing_feed
220
- return 28 if generation_mode == :append
221
- return 7 if existing_feed
261
+ def generation_days_for(start_date, generation_mode:, existing_schedule:, merge_policy:)
262
+ return 7 if merge_policy == :append && existing_schedule
263
+ return 28 if merge_policy == :append
222
264
  return 28 if generation_mode.nil?
223
265
 
224
266
  upcoming_sunday = start_date + ((7 - start_date.wday) % 7)
225
267
  (upcoming_sunday - start_date).to_i + 29
226
268
  end
227
269
 
228
- def schedule_key_for(prompt_text, start_date, catalog, generation_days, existing_feed_context)
270
+ def schedule_key_for(prompt_text, start_date, catalog, generation_days, existing_context, merge_policy)
229
271
  Digest::SHA256.hexdigest(
230
272
  [
231
273
  prompt_text.strip,
232
274
  start_date.iso8601,
233
275
  generation_days,
234
276
  catalog_digest(catalog),
235
- existing_feed_context.to_s
277
+ merge_policy.to_s,
278
+ existing_context.to_s
236
279
  ].join("\n")
237
280
  )[0...12] || ""
238
281
  end
@@ -241,7 +284,7 @@ class CoachZed
241
284
  Digest::SHA256.hexdigest(catalog.map(&:fingerprint).join("\n"))
242
285
  end
243
286
 
244
- def normalize_schedule(schedule, start_date:, prompt_text:, schedule_key:, catalog:, generation_days:)
287
+ def normalize_schedule(schedule, start_date:, prompt_text:, schedule_key:, catalog:, generation_days:, merge_policy:)
245
288
  catalog_texts = catalog.to_h { |entry| [entry.relative_path, entry.path.read] }
246
289
  days = schedule.fetch("days")
247
290
  normalized_days = days.each_with_index.map do |day, index|
@@ -266,10 +309,31 @@ class CoachZed
266
309
  "catalog_directory" => workout_catalog_dir.to_s,
267
310
  "catalog_count" => catalog.count,
268
311
  "program_length_days" => schedule.fetch("program_length_days", generation_days).to_i,
312
+ "merge_policy" => merge_policy.to_s,
313
+ "generated_at" => Time.now.utc.iso8601,
269
314
  "days" => normalized_days
270
315
  )
271
316
  end
272
317
 
318
+ def merge_schedule(existing_schedule, schedule, merge_policy)
319
+ return schedule if merge_policy == :replace || existing_schedule.nil?
320
+
321
+ existing_by_date = existing_schedule.fetch("days").to_h { |day| [day.fetch("date"), day] }
322
+ merged_by_date = existing_by_date.merge(schedule.fetch("days").to_h { |day| [day.fetch("date"), day] })
323
+
324
+ merged_days = merged_by_date.values.sort_by { |day| Date.parse(day.fetch("date")) }
325
+ merged_days = merged_days.each_with_index.map do |day, index|
326
+ day.merge("day_number" => index + 1)
327
+ end
328
+
329
+ schedule.merge(
330
+ "merged_from_schedule_id" => existing_schedule["schedule_id"],
331
+ "start_date" => merged_days.first&.fetch("date"),
332
+ "program_length_days" => merged_days.length,
333
+ "days" => merged_days
334
+ )
335
+ end
336
+
273
337
  def write_schedule(schedule, schedule_key)
274
338
  schedule_output_dir.mkpath
275
339
  path = schedule_output_dir.join(schedule_filename(schedule_key))
@@ -277,13 +341,11 @@ class CoachZed
277
341
  path
278
342
  end
279
343
 
280
- def write_feeds(schedule, start_date:, existing_feed:)
344
+ def write_feeds(schedule)
281
345
  feed_output_dir.mkpath
282
346
  base_path = feed_output_dir.join(feed_basename)
283
347
  feed = FeedWriter.new(
284
348
  schedule:,
285
- start_date:,
286
- existing_feed_content: existing_feed&.feed_content,
287
349
  calendar_name: feed_title
288
350
  ).build
289
351
  ics_path = base_path.sub_ext(".ics")
@@ -300,4 +362,16 @@ class CoachZed
300
362
  def feed_basename
301
363
  feed_output_basename || "schedule"
302
364
  end
365
+
366
+ def schedule_context(schedule, limit_days: 28)
367
+ days = schedule.fetch("days")
368
+ recent_days = days.last(limit_days)
369
+
370
+ recent_days.map do |day|
371
+ pieces = [day.fetch("date")]
372
+ pieces << ((day["day_type"] == "workout") ? day.fetch("workout").fetch("title") : "Rest")
373
+ pieces << day["notes"] if day["notes"] && !day["notes"].to_s.empty?
374
+ pieces.join(" | ")
375
+ end.join("\n")
376
+ end
303
377
  end
data/sig/coach_zed.rbs CHANGED
@@ -27,6 +27,8 @@ class CoachZed
27
27
  attr_accessor feed_output_basename: String?
28
28
  attr_accessor feed_title: String?
29
29
  attr_accessor existing_feed_path: String?
30
+ attr_accessor existing_schedule_path: String?
31
+ attr_accessor merge_policy: String?
30
32
 
31
33
  def initialize: (
32
34
  ?workout_catalog_dir: String?,
@@ -34,7 +36,9 @@ class CoachZed
34
36
  ?output_dir: String?,
35
37
  ?feed_output_basename: String?,
36
38
  ?feed_title: String?,
37
- ?existing_feed_path: String?
39
+ ?existing_feed_path: String?,
40
+ ?existing_schedule_path: String?,
41
+ ?merge_policy: String?
38
42
  ) -> void
39
43
 
40
44
  def apply: (Hash[untyped, untyped]) -> void
@@ -55,6 +59,8 @@ class CoachZed
55
59
  attr_reader feed_output_basename: String?
56
60
  attr_reader feed_title: String?
57
61
  attr_reader existing_feed_path: Pathname?
62
+ attr_reader existing_schedule_path: Pathname?
63
+ attr_reader merge_policy: String?
58
64
 
59
65
  def initialize: (
60
66
  client: untyped,
@@ -63,14 +69,17 @@ class CoachZed
63
69
  ?output_dir: String?,
64
70
  ?feed_output_basename: String?,
65
71
  ?feed_title: String?,
66
- ?existing_feed_path: String?
72
+ ?existing_feed_path: String?,
73
+ ?existing_schedule_path: String?,
74
+ ?merge_policy: String?
67
75
  ) -> void
68
76
 
69
77
  def generate_schedule: (
70
78
  start_date: untyped,
71
79
  ?consultation_prompt: String?,
72
80
  ?consultation_prompt_path: String?,
73
- ?generation_mode: Symbol?
81
+ ?generation_mode: Symbol?,
82
+ ?merge_policy: Symbol?
74
83
  ) -> Result
75
84
 
76
85
  private
@@ -79,16 +88,20 @@ class CoachZed
79
88
  def resolve_prompt_text: (String? consultation_prompt, String? consultation_prompt_path) -> String
80
89
  def normalize_date: (untyped value) -> Date
81
90
  def load_existing_feed: -> CoachZed::FeedReader?
91
+ def load_existing_schedule: -> Hash[String, untyped]?
82
92
  def normalize_generation_mode: (untyped value) -> Symbol?
83
- def generation_start_date: (untyped start_date, existing_feed: CoachZed::FeedReader?, generation_mode: Symbol?) -> Date
84
- def generation_days_for: (Date start_date, generation_mode: Symbol?, existing_feed: CoachZed::FeedReader?) -> Integer
85
- def schedule_key_for: (String prompt_text, Date start_date, Array[CoachZed::Catalog::Entry] catalog, Integer generation_days, String? existing_feed_context) -> String
93
+ def normalize_merge_policy: (untyped value) -> Symbol?
94
+ def generation_start_date: (untyped start_date, existing_schedule: Hash[String, untyped]?, generation_mode: Symbol?, merge_policy: Symbol?) -> Date
95
+ def generation_days_for: (Date start_date, generation_mode: Symbol?, existing_schedule: Hash[String, untyped]?, merge_policy: Symbol?) -> Integer
96
+ def schedule_key_for: (String prompt_text, Date start_date, Array[CoachZed::Catalog::Entry] catalog, Integer generation_days, String? existing_context, Symbol? merge_policy) -> String
86
97
  def catalog_digest: (Array[CoachZed::Catalog::Entry] catalog) -> String
87
- def normalize_schedule: (Hash[String, untyped] schedule, start_date: Date, prompt_text: String, schedule_key: String, catalog: Array[CoachZed::Catalog::Entry], generation_days: Integer) -> Hash[String, untyped]
98
+ def normalize_schedule: (Hash[String, untyped] schedule, start_date: Date, prompt_text: String, schedule_key: String, catalog: Array[CoachZed::Catalog::Entry], generation_days: Integer, merge_policy: Symbol?) -> Hash[String, untyped]
99
+ def merge_schedule: (Hash[String, untyped]?, Hash[String, untyped], Symbol?) -> Hash[String, untyped]
88
100
  def write_schedule: (Hash[String, untyped] schedule, String schedule_key) -> Pathname
89
- def write_feeds: (Hash[String, untyped] schedule, start_date: Date, existing_feed: CoachZed::FeedReader?) -> Hash[Symbol, Pathname]
101
+ def write_feeds: (Hash[String, untyped] schedule) -> Hash[Symbol, Pathname]
90
102
  def schedule_filename: (String schedule_key) -> String
91
103
  def feed_basename: -> String
104
+ def schedule_context: (Hash[String, untyped] schedule, ?limit_days: Integer) -> String
92
105
 
93
106
  module Catalog
94
107
  class Entry
@@ -196,14 +209,10 @@ class CoachZed
196
209
 
197
210
  class FeedWriter
198
211
  attr_reader schedule: Hash[String, untyped]
199
- attr_reader start_date: Date
200
- attr_reader existing_feed_content: String?
201
212
  attr_reader calendar_name: String?
202
213
 
203
214
  def initialize: (
204
215
  schedule: Hash[String, untyped],
205
- start_date: Date,
206
- ?existing_feed_content: String?,
207
216
  ?calendar_name: String?
208
217
  ) -> void
209
218
 
@@ -212,7 +221,6 @@ class CoachZed
212
221
  private
213
222
 
214
223
  def fresh_feed: -> String
215
- def append_to_existing_feed: (String existing_feed) -> String
216
224
  def header_lines: -> Array[String]
217
225
  def event_lines: -> Array[String]
218
226
  def schedule_name: -> String
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: coach_zed
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ross Kaffenberger