git-fit 0.25.0 → 0.26.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: 0b5ac295fe113517b660831a4cdaf826ac0905781ccdc591174117e0ec91973b
4
- data.tar.gz: ecafcdedbdb2469570e946bd2b885d2605d92bf43a65fed7668f0074709a9a09
3
+ metadata.gz: d61225229494679f4d293ddcfe384f79d9b6a2b7ea4007728fe45a2b47464506
4
+ data.tar.gz: 9e5934dc3360b9bf9a3dd925006379647f59140b3e2efb852b048ca3704d6fa8
5
5
  SHA512:
6
- metadata.gz: a588657ec9decf802ec4b2dc68bce7eb9e3c149282fd67e10a8e80a07c69c22cb418aef44513ba62e4de59c153475ba37dbf66da596b330d88d64601ea89f5a1
7
- data.tar.gz: 2f1cdbe55b0f3ddd59406bb3b01c8d43ea09d491ec9a47a3fb316356e35cf6834633a1aa0315e495f5f26e623b214052b6934bd025a3c620d6f869c50d707a1e
6
+ metadata.gz: 4c1637ae4f4ac30c144703f4e9043b92278e7a30d6972c47c579740a7838207c1bedfd781724cfa74baaa4d40ecd5db4a22876f9e428e3492411bd85e18abb58
7
+ data.tar.gz: f51296343450139ecb8d8446a697648464c307902e05e25d76621af17cd3af2554b8e5b5eb324a5a5e005d05e681007684ced428e1513aeb1c2a508a96a21870
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Issue workouts#37: device / app / lineage columns
4
+ # device — physical hardware that produced the track file (e.g. "XOSS SPRINT")
5
+ # app — software the user directly interacted with (e.g. "Garmin Connect")
6
+ # lineage — denormalized "device → app → source" chain for query/display
7
+ Sequel.migration do
8
+ up do
9
+ alter_table(:activities) do
10
+ add_column :device, String
11
+ add_column :app, String
12
+ add_column :lineage, String
13
+ add_index :device
14
+ add_index :app
15
+ add_index :lineage
16
+ end
17
+ end
18
+
19
+ down do
20
+ alter_table(:activities) do
21
+ drop_index :lineage
22
+ drop_index :app
23
+ drop_index :device
24
+ drop_column :lineage
25
+ drop_column :app
26
+ drop_column :device
27
+ end
28
+ end
29
+ end
data/lib/git-fit.rb CHANGED
@@ -7,6 +7,22 @@ require 'fileutils'
7
7
  module GitFit
8
8
  SPORT_CATEGORIES = %w[run ride hike walk swim ski workout other].freeze
9
9
 
10
+ # Per-source default app label (used when an adapter doesn't set `app` itself).
11
+ # Dynamic sources (strava per-device, apple_health per sourceName) set `app` explicitly.
12
+ DEFAULT_APPS = {
13
+ 'garmin' => 'Garmin Connect',
14
+ 'garmin_cn' => 'Garmin Connect',
15
+ 'keep' => 'Keep',
16
+ 'igpsport' => 'iGPSPORT',
17
+ 'xoss' => 'XOSS',
18
+ 'codoon' => 'Codoon',
19
+ 'joyrun' => 'Joyrun',
20
+ 'xingzhe' => 'XingZhe',
21
+ 'strava' => 'Strava',
22
+ '2bulu' => '2bulu',
23
+ 'apple_health' => 'Apple Health',
24
+ }.freeze
25
+
10
26
  @registered_configs = []
11
27
 
12
28
  class << self
@@ -37,6 +53,24 @@ module GitFit
37
53
  }
38
54
  end
39
55
 
56
+ # Resolved `app` label for an activity: explicit value wins, else DEFAULT_APPS, else raw source.
57
+ def default_app(app, source)
58
+ return app unless app.nil? || app.to_s.empty?
59
+ DEFAULT_APPS.fetch(source, source)
60
+ end
61
+
62
+ # Lineage chain "device → app → source"; blank parts dropped, case-insensitive
63
+ # dedup so app==source collapses (Keep + keep → "Keep"). Display-only, never
64
+ # used for dedup decisions (workouts#37).
65
+ def build_lineage(device, app, source)
66
+ seen = []
67
+ [device, app, source].each do |part|
68
+ next if part.nil? || part.to_s.empty?
69
+ seen << part unless seen.any? { |s| s.casecmp?(part) }
70
+ end
71
+ seen.join(' → ')
72
+ end
73
+
40
74
  def format_time(seconds)
41
75
  return nil unless seconds
42
76
  hours = seconds / 3600
@@ -44,13 +44,11 @@ module GitFit
44
44
  def normalize_as_of(as_of)
45
45
  return nil if as_of.nil? || as_of.to_s.empty?
46
46
 
47
- valid = as_of.to_s.match?(/\A\d{4}-\d{2}-\d{2}\z/)
48
- unless valid
49
- raise ArgumentError, "Ability: invalid --as-of '#{as_of}' (expected YYYY-MM-DD)\n" \
50
- ' Fix: git fit ability compute --as-of 2025-03-15'
51
- end
52
-
47
+ Date.iso8601(as_of.to_s)
53
48
  as_of
49
+ rescue Date::Error
50
+ raise ArgumentError, "Ability: invalid --as-of '#{as_of}' (expected YYYY-MM-DD)\n" \
51
+ ' Fix: git fit ability compute --as-of 2025-03-15'
54
52
  end
55
53
 
56
54
  def load_rows
@@ -99,21 +97,19 @@ module GitFit
99
97
  # --- fitness layer ---
100
98
 
101
99
  def fitness_snapshots(rows)
102
- base = {}
103
- Snapshot::PERIOD_TYPES.each do |type|
104
- buckets = bucketize(rows, type)
105
- buckets.each { |key, period_rows| base[[type, key]] = fitness_fields(type, key, period_rows) }
106
- end
107
- out = []
108
- Snapshot::PERIOD_TYPES.each do |type|
109
- bucketize(rows, type).keys.sort.each do |key|
100
+ buckets = Snapshot::PERIOD_TYPES.to_h { |type| [type, bucketize(rows, type)] }
101
+ base = buckets.flat_map do |type, per|
102
+ per.map do |key, period_rows|
103
+ [[type, key], fitness_fields(type, key, period_rows)]
104
+ end
105
+ end.to_h
106
+ Snapshot::PERIOD_TYPES.flat_map do |type|
107
+ buckets[type].keys.sort.map do |key|
110
108
  fields = base[[type, key]]
111
- prev_key = Snapshot.previous_key(type, key)
112
- prev = prev_key && base[[type, prev_key]]
113
- out << fields.merge('trend_vs_prev' => prev ? trend_fields(fields, prev) : nil)
109
+ prev = Snapshot.previous_key(type, key)&.then { |pk| base[[type, pk]] }
110
+ fields.merge('trend_vs_prev' => prev ? trend_fields(fields, prev) : nil)
114
111
  end
115
112
  end
116
- out
117
113
  end
118
114
 
119
115
  def bucketize(rows, type)
@@ -84,21 +84,18 @@ module GitFit
84
84
  format('%<year>04d-W%<week>02d', year: date.cwyear, week: date.cweek)
85
85
  end
86
86
 
87
- # Distinct ISO weeks overlapping [start, end].
87
+ # Distinct ISO weeks overlapping [start, end] (cwyear*53+cweek is monotonic;
88
+ # cweek never exceeds 53).
88
89
  def week_count(start_date, end_date)
89
- keys = {}
90
- (start_date..end_date).each { |d| keys[week_key_of_date(d)] = true }
91
- keys.size
90
+ (end_date.cwyear * 53 + end_date.cweek) - (start_date.cwyear * 53 + start_date.cweek) + 1
92
91
  end
93
92
 
94
93
  # Sample-count confidence: <3 low (prompt-excluded), 3-9 medium, >=10 high.
95
94
  def confidence_for(count)
96
95
  if count < 3
97
96
  'low'
98
- elsif count < 10
99
- 'medium'
100
97
  else
101
- 'high'
98
+ count < 10 ? 'medium' : 'high'
102
99
  end
103
100
  end
104
101
 
@@ -29,33 +29,5 @@ module GitFit
29
29
  rescue StandardError => e
30
30
  say_status :error, "Ability compute failed: #{e.message}", :red
31
31
  end
32
-
33
- desc 'show', 'Print a single ability snapshot by period key'
34
- option :period, type: :string, required: true, desc: 'Period key, e.g. 2025-03 / 2025-Q1 / 2025-W12 / 2025 / all'
35
- option :output, type: :string, aliases: '-o', desc: 'ability.json path to read'
36
-
37
- def show
38
- path = options[:output] || git_fit_config.export_config.dig('ability', 'path') || 'site/ability.json'
39
- doc = JSON.parse(File.read(path))
40
- snapshot = find_snapshot(doc, options[:period])
41
- if snapshot
42
- puts JSON.pretty_generate(snapshot)
43
- else
44
- say_status :warn, "Ability: no snapshot for period '#{options[:period]}' in #{path}", :yellow
45
- end
46
- rescue StandardError => e
47
- say_status :error, "Ability show failed: #{e.message}", :red
48
- end
49
-
50
- private
51
-
52
- def find_snapshot(doc, period_key)
53
- candidates = (doc.dig('fitness', 'snapshots') || []) +
54
- (doc['sports'] || {}).values.flat_map { |s| s['snapshots'] || [] }
55
- matches = candidates.select { |s| s['period_key'] == period_key }
56
- return matches.first if matches.one?
57
-
58
- matches # both fitness and sport layers may carry the key — print all
59
- end
60
32
  end
61
33
  end
@@ -85,6 +85,9 @@ module GitFit
85
85
  elevation_max: a[:elevation_max_terrain] || a[:elevation_max],
86
86
  steps: a[:steps],
87
87
  source: a[:source],
88
+ device: a[:device],
89
+ app: a[:app],
90
+ lineage: a[:lineage],
88
91
  }
89
92
  if a[:divisions]
90
93
  base[:divisions] = ::JSON.parse(a[:divisions])