Module: AlFolioCv::DateSorting

Defined in:
lib/al_folio_cv/date_sorting.rb

Overview

Orders CV entries the way a CV is normally read: most recent first, with ongoing entries at the top.

CV data reaches the templates from two sources that disagree on key names (RenderCV uses start_date/end_date, JSONResume uses startDate/endDate) and both allow partial dates ("2020", "2020-06"), textual end dates ("present") and entries with no dates at all. Everything here is therefore total: an unrecognised value is treated as "no date" rather than raising.

Constant Summary collapse

START_KEYS =
%w[start_date startDate].freeze
END_KEYS =
%w[end_date endDate].freeze
POINT_KEYS =
%w[date releaseDate].freeze
ONGOING_VALUES =
%w[present current ongoing now].freeze
PARTIAL_DATE =

"2020", "2020-06", "2020-06-01" and the "/" and "." separated variants.

%r{\A(\d{4})(?:[-/.](\d{1,2}))?(?:[-/.](\d{1,2}))?\z}
BARE_YEAR =

Last-resort fallback for free-form values such as "Fall 2019".

/(?<!\d)(\d{4})(?!\d)/
ONGOING_RANK =
Float::INFINITY
UNDATED_RANK =
-Float::INFINITY

Class Method Summary collapse

Class Method Details

.bounds(entry) ⇒ Object

Resolves an entry to a [start_rank, end_rank] pair of comparable numbers.



50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# File 'lib/al_folio_cv/date_sorting.rb', line 50

def bounds(entry)
  start_raw = first_present(entry, START_KEYS)
  end_raw = first_present(entry, END_KEYS)
  point_raw = first_present(entry, POINT_KEYS)

  start_rank = parse(start_raw, :start)

  end_rank =
    if ongoing?(end_raw)
      ONGOING_RANK
    elsif end_raw
      parse(end_raw, :end)
    elsif start_rank
      # A start date with no end date means the entry is still running,
      # which is also how the templates render it ("2020 - Present"). A
      # standalone `date` is a point in time and never counts as ongoing.
      ONGOING_RANK
    else
      parse(point_raw, :end)
    end

  [start_rank || parse(point_raw, :start) || UNDATED_RANK, end_rank || UNDATED_RANK]
end

.clamp(value, min, max) ⇒ Object



112
113
114
115
116
117
118
# File 'lib/al_folio_cv/date_sorting.rb', line 112

def clamp(value, min, max)
  value = value.to_i
  return min if value < min
  return max if value > max

  value
end

.compose(year, month, day) ⇒ Object



108
109
110
# File 'lib/al_folio_cv/date_sorting.rb', line 108

def compose(year, month, day)
  (year.to_i * 10_000) + (clamp(month, 1, 12) * 100) + clamp(day, 1, 31)
end

.compose_partial(year, month, day, boundary) ⇒ Object



100
101
102
103
104
105
106
# File 'lib/al_folio_cv/date_sorting.rb', line 100

def compose_partial(year, month, day, boundary)
  if boundary == :end
    compose(year, month || 12, day || 31)
  else
    compose(year, month || 1, day || 1)
  end
end

.first_present(entry, keys) ⇒ Object

First non-nil, non-blank value among the given keys.



121
122
123
124
125
126
127
128
129
130
# File 'lib/al_folio_cv/date_sorting.rb', line 121

def first_present(entry, keys)
  keys.each do |key|
    value = lookup(entry, key)
    next if value.nil?
    next if value.is_a?(String) && value.strip.empty?

    return value
  end
  nil
end

.lookup(entry, key) ⇒ Object



132
133
134
135
136
137
138
139
140
# File 'lib/al_folio_cv/date_sorting.rb', line 132

def lookup(entry, key)
  case entry
  when Hash then entry[key].nil? ? entry[key.to_sym] : entry[key]
  else
    return nil unless entry.respond_to?(:[]) && entry.respond_to?(:key?)

    entry[key]
  end
end

.ongoing?(value) ⇒ Boolean

Returns:

  • (Boolean)


74
75
76
# File 'lib/al_folio_cv/date_sorting.rb', line 74

def ongoing?(value)
  value.is_a?(String) && ONGOING_VALUES.include?(value.strip.downcase)
end

.parse(value, boundary) ⇒ Object

Turns a date value into an integer of the form YYYYMMDD. Missing components are padded to the start or the end of the period so that a year-only end date ("2020") sorts after a mid-year one ("2020-06").



81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# File 'lib/al_folio_cv/date_sorting.rb', line 81

def parse(value, boundary)
  case value
  when nil then nil
  when Date, DateTime, Time then compose(value.year, value.month, value.day)
  when Numeric then compose_partial(value.to_i, nil, nil, boundary)
  when String, Symbol
    text = value.to_s.strip
    return nil if text.empty?

    if (match = PARTIAL_DATE.match(text))
      compose_partial(match[1].to_i, match[2]&.to_i, match[3]&.to_i, boundary)
    elsif (match = BARE_YEAR.match(text))
      compose_partial(match[1].to_i, nil, nil, boundary)
    end
    # Anything else (nested hashes, arrays, ...) counts as "no date"; its
    # `to_s` could otherwise contribute a stray four-digit number.
  end
end

.sort(entries) ⇒ Object

Returns a new array ordered by end date descending, then start date descending. Ongoing entries come first, undated entries last, and the original order breaks ties so the sort is stable.



35
36
37
38
39
40
41
42
# File 'lib/al_folio_cv/date_sorting.rb', line 35

def sort(entries)
  return entries unless entries.is_a?(Array)

  entries.each_with_index
         .map { |entry, index| [entry, sort_key(entry, index)] }
         .sort_by(&:last)
         .map(&:first)
end

.sort_key(entry, index) ⇒ Object



44
45
46
47
# File 'lib/al_folio_cv/date_sorting.rb', line 44

def sort_key(entry, index)
  start_rank, end_rank = bounds(entry)
  [-end_rank, -start_rank, index]
end