Module: Menuconform::Rules::AvailRules

Defined in:
lib/menuconform/rules/avail_rules.rb

Overview

AVAIL-001..003. Weekly-window math: windows expand to minute intervals over a 10080-minute week; end < start wraps past midnight; '24:00' is end-of-day. Date bounds (start_date/end_date) are ignored for overlap checks in this release — AVAIL-001 reasons about the recurring weekly pattern only.

Constant Summary collapse

DAYS =
%w[mon tue wed thu fri sat sun].freeze
WEEK_MINUTES =
7 * 1440
SUSPENSION_HORIZON_SECONDS =
365 * 24 * 3600

Class Method Summary collapse

Class Method Details

.call(menu) ⇒ Object



18
19
20
# File 'lib/menuconform/rules/avail_rules.rb', line 18

def call(menu)
  contradictions(menu) + declared_never(menu) + indefinite_suspensions(menu)
end

.contradictions(menu) ⇒ Object



22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# File 'lib/menuconform/rules/avail_rules.rb', line 22

def contradictions(menu)
  out = []
  menu_windows = menu.doc["availability"]

  menu.categories_by_id.each_value do |c|
    next unless present?(c["availability"]) && present?(menu_windows)
    next if overlap?(c["availability"], menu_windows)
    out << Finding.new("AVAIL-001", "category", c["id"],
                       "category windows never overlap the menu's windows")
  end

  menu.items_by_id.each_value do |it|
    next unless present?(it["availability"])
    contexts = menu.categories.select { |c| (c["item_ids"] || []).include?(it["id"]) }
                   .map { |c| present?(c["availability"]) ? c["availability"] : menu_windows }
    contexts = [menu_windows] if contexts.empty?
    contexts = contexts.select { |w| present?(w) }
    next if contexts.empty? # nothing to contradict
    next if contexts.any? { |w| overlap?(it["availability"], w) }
    out << Finding.new("AVAIL-001", "item", it["id"],
                       "item windows never overlap its category/menu windows — never orderable")
  end
  out
end

.declared_never(menu) ⇒ Object



47
48
49
50
51
52
53
54
55
56
# File 'lib/menuconform/rules/avail_rules.rb', line 47

def declared_never(menu)
  out = []
  menu.categories_by_id.each_value do |c|
    out << Finding.new("AVAIL-002", "category", c["id"], "availability is an explicit empty list") if c["availability"] == []
  end
  menu.items_by_id.each_value do |it|
    out << Finding.new("AVAIL-002", "item", it["id"], "availability is an explicit empty list") if it["availability"] == []
  end
  out
end

.indefinite_suspensions(menu) ⇒ Object



58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
# File 'lib/menuconform/rules/avail_rules.rb', line 58

def indefinite_suspensions(menu)
  horizon = menu.reference_time + SUSPENSION_HORIZON_SECONDS
  check = lambda do |entity, type|
    raw = entity["suspended_until"]
    return nil unless raw
    t = begin
      Time.iso8601(raw)
    rescue ArgumentError
      nil
    end
    return nil unless t && t > horizon
    Finding.new("AVAIL-003", type, entity["id"],
                "suspended_until #{raw} is more than a year past the reference time — retirement via 86")
  end
  menu.items_by_id.each_value.filter_map { |it| check.call(it, "item") } +
    menu.modifiers_by_id.each_value.filter_map { |m| check.call(m, "modifier") }
end

.intervals(windows) ⇒ Object



86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/menuconform/rules/avail_rules.rb', line 86

def intervals(windows)
  windows.flat_map do |w|
    days = w["days"] || DAYS
    s = minute_of_day(w["start"])
    e = minute_of_day(w["end"])
    days.flat_map do |d|
      di = DAYS.index(d)
      next [] if di.nil? || s.nil? || e.nil?
      base = di * 1440
      if e > s
        [[base + s, base + e]]
      elsif e < s # wraps past midnight into the next day
        nxt = ((di + 1) % 7) * 1440
        [[base + s, base + 1440], [nxt, nxt + e]].reject { |i| i[0] == i[1] }
      else
        [] # zero-length window
      end
    end
  end
end

.minute_of_day(hhmm) ⇒ Object



107
108
109
110
111
# File 'lib/menuconform/rules/avail_rules.rb', line 107

def minute_of_day(hhmm)
  return nil unless hhmm.is_a?(String) && hhmm.match?(/\A\d{2}:\d{2}\z/)
  h, m = hhmm.split(":").map(&:to_i)
  (h * 60) + m
end

.overlap?(windows_a, windows_b) ⇒ Boolean

Returns:

  • (Boolean)


80
81
82
83
84
# File 'lib/menuconform/rules/avail_rules.rb', line 80

def overlap?(windows_a, windows_b)
  ints_a = intervals(windows_a)
  ints_b = intervals(windows_b)
  ints_a.any? { |a| ints_b.any? { |b| a[0] < b[1] && b[0] < a[1] } }
end

.present?(windows) ⇒ Boolean

Returns:

  • (Boolean)


76
77
78
# File 'lib/menuconform/rules/avail_rules.rb', line 76

def present?(windows)
  !windows.nil? && !windows.empty?
end