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
-
.bounds(entry) ⇒ Object
Resolves an entry to a [start_rank, end_rank] pair of comparable numbers.
- .clamp(value, min, max) ⇒ Object
- .compose(year, month, day) ⇒ Object
- .compose_partial(year, month, day, boundary) ⇒ Object
-
.first_present(entry, keys) ⇒ Object
First non-nil, non-blank value among the given keys.
- .lookup(entry, key) ⇒ Object
- .ongoing?(value) ⇒ Boolean
-
.parse(value, boundary) ⇒ Object
Turns a date value into an integer of the form YYYYMMDD.
-
.sort(entries) ⇒ Object
Returns a new array ordered by end date descending, then start date descending.
- .sort_key(entry, index) ⇒ Object
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
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 |