lesli_date 1.0.0 → 1.1.1

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.
@@ -0,0 +1,101 @@
1
+ # Parsing rules
2
+
3
+ LesliDate uses Ruby's date parsers and Rails timezone conversion. It does not
4
+ compile input patterns or maintain its own validation rules for every field.
5
+
6
+ ```ruby
7
+ LesliDate::Formatter.new(value, format = nil, input_zone: "UTC", zone: nil, locale: nil)
8
+ ```
9
+
10
+ ## Input values
11
+
12
+ | Input | Behavior | `.get` result |
13
+ | --- | --- | --- |
14
+ | No argument | Current instant | `ActiveSupport::TimeWithZone` |
15
+ | `Time`, `TimeWithZone`, `DateTime` | Preserve the instant and apply the presentation zone | `ActiveSupport::TimeWithZone` |
16
+ | `Date` | Preserve the calendar date | `Date` |
17
+ | String without a pattern | Use `DateTime.parse` | `Date` or `ActiveSupport::TimeWithZone` |
18
+ | String with a pattern | Use `DateTime.strptime` | `Date` or `ActiveSupport::TimeWithZone` |
19
+ | Other types, including explicit `nil` | Reject with `LesliDate::ParseError` | No value |
20
+
21
+ The native parser's metadata distinguishes a calendar date from an instant.
22
+ Calendar dates support `.date`, `.date_words`, and `.date_words_day`. Time
23
+ selectors on dates raise `LesliDate::InvalidFormat` instead of inventing an hour.
24
+ Selecting `.date` on an instant preserves its full underlying value in `.get`.
25
+
26
+ ## Native input patterns
27
+
28
+ The second positional argument is a Ruby `strptime` pattern. It supports native
29
+ directives, including compound directives, month names, offsets and fractions:
30
+
31
+ ```ruby
32
+ LesliDate::Formatter.new("22/10/2026", "%d/%m/%Y").get
33
+ # => Date.new(2026, 10, 22)
34
+
35
+ LesliDate::Formatter.new("22 October 2026", "%d %B %Y").date.to_s
36
+ LesliDate::Formatter.new("2026-10-22 14:30:45", "%F %T").date_time.to_s
37
+ LesliDate::Formatter.new("2026-10-22T14:30:45.123Z").date_time.to_s
38
+ ```
39
+
40
+ When a pattern is supplied, LesliDate calls `strptime` directly and does not
41
+ retry with `parse`. An unparseable value or a mismatch raises `ParseError` with
42
+ the native exception as its cause.
43
+
44
+ Accepted syntax, missing-component defaults, precision, calendar conventions,
45
+ and tolerance follow Ruby. For example, native parsing may accept partial dates
46
+ or a valid prefix with trailing text. LesliDate does not add complete-input
47
+ validation, a directive allowlist, year bounds, encoding conversion, or manual
48
+ range checks. Patterns apply to string inputs; temporal objects are used directly.
49
+
50
+ ## UTC input and configured output
51
+
52
+ Lesli stores database timestamps in UTC. The initializer's
53
+ `Lesli.config.datetime[:time_zone]` controls presentation. Normal Lesli calls
54
+ need no zone options:
55
+
56
+ ```ruby
57
+ # With America/Guatemala and output format %d/%m/%Y %H:%M:
58
+ LesliDate::Formatter.new("2026-10-22 14:30:00").date_time.to_s
59
+ # => "22/10/2026 08:30"
60
+ ```
61
+
62
+ For strings containing a time but no offset, `input_zone` defaults to UTC.
63
+ Strings with an explicit offset already determine an instant. `zone` overrides
64
+ the configured presentation zone for one formatter. Dates do not undergo zone
65
+ conversion.
66
+
67
+ ```ruby
68
+ # An external source supplies local Guatemala time rather than UTC.
69
+ LesliDate::Formatter.new(
70
+ "22/10/2026 14:30",
71
+ "%d/%m/%Y %H:%M",
72
+ input_zone: "America/Guatemala",
73
+ zone: "UTC"
74
+ ).date_time.to_s
75
+ # => "22.10.2026 20:30", with output format %d.%m.%Y %H:%M
76
+ ```
77
+
78
+ Zone names use `ActiveSupport::TimeZone`. Local times, including clock-change
79
+ gaps and overlaps, follow Rails' native `TimeZone#local` behavior. LesliDate does
80
+ not implement a separate daylight-saving validation policy.
81
+
82
+ ## Exceptions
83
+
84
+ | Error | Meaning |
85
+ | --- | --- |
86
+ | `LesliDate::ParseError` | Unsupported input type or an exception from native parsing |
87
+ | `LesliDate::InvalidTimeZone` | Missing or unknown zone name; an unsupported SQLite output zone when generating SQL |
88
+ | `LesliDate::InvalidFormat` | Invalid output configuration, a time selector applied to a calendar date, or an unsupported PostgreSQL SQL-format directive |
89
+
90
+ All inherit from `LesliDate::Error < ArgumentError`.
91
+ I18n locale/translation failures keep their native exception behavior. SQL
92
+ execution errors come from ActiveRecord; unsupported SQLite format directives
93
+ return native SQL `NULL` rather than a LesliDate exception.
94
+
95
+ ```ruby
96
+ begin
97
+ value = LesliDate::Formatter.new(params[:date], "%d/%m/%Y").get
98
+ rescue LesliDate::ParseError
99
+ # Present the application's validation message.
100
+ end
101
+ ```
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" fill="#0c2856" viewBox="0 0 100 100"><desc>A formatted calendar paired with a clock.</desc><rect width="61" height="67" x="8" y="19" rx="7"/><rect width="49" height="43" x="14" y="35" fill="#d8e1ef" rx="3"/><rect width="8" height="18" x="20" y="12" rx="4"/><rect width="8" height="18" x="48" y="12" rx="4"/><rect width="9" height="8" x="20" y="43" rx="2"/><rect width="9" height="8" x="34" y="43" rx="2"/><rect width="9" height="8" x="48" y="43" rx="2"/><rect width="9" height="8" x="20" y="57" rx="2"/><rect width="9" height="8" x="34" y="57" rx="2"/><circle cx="73" cy="68" r="21"/><circle cx="73" cy="68" r="14" fill="#d8e1ef"/><path d="M70 56h6v11l8 5-3 5-11-7Z"/></svg>
data/docs/navigation ADDED
@@ -0,0 +1,2 @@
1
+ <%= navigation_for_folder("gems", "date", "about", order:["installation", "configuration", "development"]) %>
2
+ <%= navigation_for_folder("gems", "date", "api", order:["overview", "formatter", "parsing", "database", "examples"]) %>
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LesliDate
4
+ class Error < ArgumentError; end
5
+ class ParseError < Error; end
6
+ class InvalidTimeZone < Error; end
7
+ class InvalidFormat < Error; end
8
+ end
@@ -32,14 +32,34 @@ Building a better future, one line of code at a time.
32
32
 
33
33
  # maybe move this to a "LDate" gem?
34
34
 
35
+ require "lesli_date/parser"
36
+ require "active_record"
37
+ require "i18n"
38
+
35
39
  module LesliDate
36
40
  class Formatter
37
41
 
38
- def initialize(datetime = Time.current, format = "%Y-%m-%d %H:%M:%S")
42
+ DEFAULT_FORMATS = {
43
+ :date => "%d.%m.%Y",
44
+ :time => "%H:%M",
45
+ :date_time => "%d.%m.%Y %H:%M",
46
+ :date_words => "%B %d, %Y", # date in words including day's name
47
+ :date_words_day => "%A, %B %d, %Y", # date in words including day's name
48
+ :date_time_words => "%B %d, %Y, %H:%M", # 24 hours datetime in words format (default)
49
+ :date_time_words_pm => "%B %d, %Y, %I:%M %p", # 12 hours datetime in words format
50
+ :date_time_words_day => "%A, %B %d, %Y, %H:%M", # 24 hours datetime in words format (default)
51
+ }.freeze
52
+ private_constant :DEFAULT_FORMATS
53
+
54
+ def initialize(datetime = Time.current, format = nil, input_zone: "UTC", zone: nil, locale: nil)
39
55
 
40
56
  # NOTE: user should be able to change this through settings table
41
57
  # get initial datetime configuration
42
- config = Lesli.config.datetime
58
+ config = Lesli.config.datetime || {}
59
+ configured_zone = config[:time_zone]
60
+ output_zone = zone.nil? ? configured_zone : zone
61
+ # Lesli stores timestamps in UTC; the configured zone controls presentation.
62
+ @parser = Parser.new(input_zone: input_zone, zone: output_zone)
43
63
 
44
64
 
45
65
  # NOTE: Do not modify settings here,
@@ -63,19 +83,11 @@ module LesliDate
63
83
  # }
64
84
 
65
85
  @settings = {
66
- :time_zone => config[:time_zone],
67
- :start_week_on => config[:start_week_on],
68
- :format => {
69
- :date => config[:formats][:date] || "%d.%m.%Y",
70
- :time => "%H:%M",
71
- :date_time => config[:formats][:date_time] || "%d.%m.%Y %H:%M",
72
- :date_words => "%B %d, %Y", # date in words including day's name
73
- :date_words_day => "%A, %B %d, %Y", # date in words including day's name
74
- :date_time_words => "%B %d, %Y, %H:%M", # 24 hours datetime in words format (default)
75
- :date_time_words_pm => "%B %d, %Y, %I:%M %p", # 12 hours datetime in words format
76
- :date_time_words_day => "%A, %B %d, %Y, %H:%M", # 24 hours datetime in words format (default)
77
- }
86
+ :time_zone => @parser.output_zone.name,
87
+ :start_week_on => config[:start_week_on] || "monday",
88
+ :format => configured_formats(config[:formats])
78
89
  }
90
+ @locale = locale
79
91
 
80
92
 
81
93
  # default date format
@@ -83,25 +95,16 @@ module LesliDate
83
95
 
84
96
 
85
97
  # get a valid timezone
86
- @zone = ActiveSupport::TimeZone.new(@settings[:time_zone])
98
+ @zone = @parser.output_zone
87
99
 
88
100
 
89
101
  # get datetime object from user params
90
- @datetime = parse_initial_datetime(datetime, format).in_time_zone(@zone)
102
+ @datetime = parse_initial_datetime(datetime, format)
91
103
 
92
104
  end
93
105
 
94
106
  # Dynamically define formatting methods like `date`, `time`, etc.
95
- [
96
- :date,
97
- :time,
98
- :date_time,
99
- :date_words,
100
- :date_words_day,
101
- :date_time_words,
102
- :date_time_words_pm,
103
- :date_time_words_day
104
- ].each do |key|
107
+ DEFAULT_FORMATS.each_key do |key|
105
108
  define_method(key) do
106
109
  set_format(key)
107
110
  self
@@ -110,47 +113,43 @@ module LesliDate
110
113
 
111
114
  # return query string to get timestamps columns from database
112
115
  def db_timestamps table=""
113
-
114
- # avoid ambiguous columns
115
- table = table.concat(".") if table != ""
116
-
117
- # get right format for dates
118
- format = self.db_format
119
-
120
- # compatibility for SQLite
121
- if ActiveRecord::Base.connection.adapter_name == "SQLite"
122
- offset = ActiveSupport::TimeZone[@settings[:time_zone]].utc_offset / 3600
123
- return "strftime('#{format}', #{table}created_at, '#{offset} hours') as created_at_string"
124
- end
125
-
126
- "TO_CHAR(#{table}created_at at time zone 'utc' at time zone '#{@settings[:time_zone]}', '#{format}') as created_at_date, TO_CHAR(#{table}updated_at at time zone 'utc' at time zone '#{@settings[:time_zone]}', '#{format}') as updated_at_date"
127
-
116
+ [:created_at, :updated_at].map { |column| db_column(column, table) }.join(", ")
128
117
  end
129
118
 
130
119
  # return query string to get a datetime column from database
131
120
  def db_column column, table="", as:nil
132
-
133
- as = "#{column}_string" unless as
121
+ connection = ActiveRecord::Base.connection
134
122
 
135
123
  # avoid ambiguous columns
136
- table = table.concat(".") if table != ""
124
+ identifier = connection.quote_column_name(column.to_s)
125
+ identifier = "#{connection.quote_table_name(table.to_s)}.#{identifier}" unless table.to_s.empty?
137
126
 
138
127
  # get right format for dates
139
- format = self.db_format
128
+ format = connection.quote(db_format)
140
129
 
141
130
  # compatibility for SQLite
142
- if ActiveRecord::Base.connection.adapter_name == "SQLite"
143
- offset = ActiveSupport::TimeZone[@settings[:time_zone]].utc_offset / 3600
144
- return "strftime('#{format}', #{table}#{column}, '#{offset} hours') as #{as}"
131
+ case connection.adapter_name
132
+ when "SQLite"
133
+ # SQLite supports fixed offsets, including fractional hours, but not IANA rules.
134
+ period = @zone.tzinfo.period_for_utc(Time.current.utc)
135
+ raise InvalidTimeZone, "SQLite requires a fixed output offset; use UTC or format #{@zone.name} in Ruby" if period.ends_at
136
+
137
+ offset = connection.quote("#{period.observed_utc_offset} seconds")
138
+ sql = "strftime(#{format}, #{identifier}, #{offset})"
139
+ when "PostgreSQL"
140
+ zone = connection.quote(@zone.tzinfo.identifier)
141
+ sql = "TO_CHAR(#{identifier} AT TIME ZONE 'UTC' AT TIME ZONE #{zone}, #{format})"
142
+ else
143
+ raise NotImplementedError, "LesliDate SQL does not support #{connection.adapter_name}"
145
144
  end
146
145
 
147
- "TO_CHAR(#{table}#{column} at time zone 'utc' at time zone '#{@settings[:time_zone]}', '#{format}') as #{as}"
148
-
146
+ return sql if as == false
147
+ "#{sql} AS #{connection.quote_column_name((as || "#{column}_string").to_s)}"
149
148
  end
150
149
 
151
150
  # convert a datetime object to string representation using defined format
152
151
  def to_s
153
- @datetime.strftime(@format)
152
+ I18n.localize(@datetime, format: @format, locale: @locale)
154
153
  end
155
154
 
156
155
  def get
@@ -159,43 +158,68 @@ module LesliDate
159
158
 
160
159
  private
161
160
 
161
+ def configured_formats(formats)
162
+ formats = {} if formats.nil?
163
+ raise InvalidFormat, "datetime[:formats] must be a hash" unless formats.is_a?(Hash)
162
164
 
163
- # get datetime object or string datetime and return a datetime object
164
- def parse_initial_datetime datetime, format
165
-
166
- return datetime if datetime.is_a?(Time)
165
+ DEFAULT_FORMATS.merge(formats.compact).to_h do |name, pattern|
166
+ unless pattern.is_a?(String) && pattern.valid_encoding? && !pattern.strip.empty?
167
+ raise InvalidFormat, "Format #{name} must be a nonempty string"
168
+ end
167
169
 
168
- if datetime.is_a?(::String) and datetime.size == 25 and datetime[10] == "T"
169
- return ::DateTime.iso8601(datetime)
170
+ [name, validated_format(pattern)]
170
171
  end
172
+ end
171
173
 
172
- return ::DateTime.strptime(datetime, format)
174
+ def validated_format(pattern)
175
+ Time.current.strftime(pattern)
176
+ pattern.dup.freeze
177
+ rescue ArgumentError => error
178
+ raise InvalidFormat, error.message
173
179
  end
174
180
 
175
181
 
176
- def set_format format
177
- @format = @settings[:format][format || :date]
182
+ # get datetime object or string datetime and return a datetime object
183
+ def parse_initial_datetime datetime, format
184
+ # Calendar dates remain Dates; the parser converts only instants.
185
+ @parser.parse(datetime, format)
178
186
  end
179
187
 
180
188
 
181
- def db_format
182
- format = @format
183
-
184
- # SQLite format dates are the same of ruby format
185
- return format if ActiveRecord::Base.connection.adapter_name == "SQLite"
189
+ def set_format format
190
+ if @datetime.is_a?(::Date) && ![:date, :date_words, :date_words_day].include?(format || :date)
191
+ raise InvalidFormat, "A calendar date cannot be formatted with a time selector"
192
+ end
186
193
 
187
- # Convert Ruby to postgresql date format
188
- format = format.gsub("%Y", "YYYY")
189
- format = format.gsub("%m", "MM")
190
- format = format.gsub("%d", "DD")
194
+ @format = @settings[:format][format || :date]
195
+ end
191
196
 
192
- # Convert Ruby to postgresql time format
193
- format = format.gsub("%H", "HH24") # 24 hours format
194
- format = format.gsub("%I", "HH12") # 12 hours format
195
- format = format.gsub("%p", "AM") # Meridian indicator (AM/PM)
196
- format = format.gsub("%M", "MI")
197
+ def db_format
198
+ # SQLite format dates are the same of ruby format
199
+ return @format if ActiveRecord::Base.connection.adapter_name == "SQLite"
200
+
201
+ formats = {
202
+ # Convert Ruby to postgresql date format
203
+ "%Y" => "YYYY", "%y" => "YY", "%m" => "MM", "%d" => "DD",
204
+ "%B" => "FMMonth", "%b" => "FMMon", "%A" => "FMDay", "%a" => "FMDy",
205
+ "%j" => "DDD", "%F" => "YYYY-MM-DD",
206
+ # Convert Ruby to postgresql time format
207
+ "%H" => "HH24", # 24 hours format
208
+ "%I" => "HH12", # 12 hours format
209
+ "%p" => "AM", # Meridian indicator (AM/PM)
210
+ "%P" => "am", "%M" => "MI", "%S" => "SS",
211
+ "%R" => "HH24:MI", "%T" => "HH24:MI:SS", "%%" => '"%"'
212
+ }
197
213
 
198
- format
214
+ @format.gsub(/%.|[^%]+|%/) do |part|
215
+ if part.start_with?("%")
216
+ formats.fetch(part) { raise InvalidFormat, "PostgreSQL does not support #{part.inspect}" }
217
+ else
218
+ # Quoted text stays literal inside PostgreSQL's format template.
219
+ '"' + part.gsub(/["\\]/) { |character| "\\#{character}" } + '"'
220
+ end
221
+ end
199
222
  end
223
+
200
224
  end
201
225
  end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/time"
5
+ require "lesli_date/errors"
6
+
7
+ module LesliDate
8
+ # Calendar dates remain Dates; only instants undergo timezone conversion.
9
+ class Parser
10
+ attr_reader :output_zone
11
+
12
+ def initialize(input_zone:, zone:)
13
+ @output_zone = resolve_zone(zone)
14
+ @input_zone = resolve_zone(input_zone)
15
+ end
16
+
17
+ def parse(value, format = nil)
18
+ case value
19
+ when String
20
+ parse_string(value, format)
21
+ when Time, ActiveSupport::TimeWithZone
22
+ value.in_time_zone(@output_zone)
23
+ when DateTime
24
+ value.new_start(Date::GREGORIAN).in_time_zone(@output_zone)
25
+ when Date
26
+ value
27
+ else
28
+ raise ParseError, "Expected a date, time or date string"
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def resolve_zone(name)
35
+ zone = ActiveSupport::TimeZone[name] if name.is_a?(String)
36
+ zone || raise(InvalidTimeZone, "Unknown time zone: #{name.inspect}")
37
+ end
38
+
39
+ def parse_string(value, format)
40
+ value = value.strip
41
+ datetime = format ? DateTime.strptime(value, format) : DateTime.parse(value)
42
+ parts = format ? Date._strptime(value, format) : Date._parse(value)
43
+
44
+ # Native parsing metadata distinguishes a date from a time or explicit offset.
45
+ return datetime.to_date if (parts.keys & [:hour, :min, :sec, :seconds, :offset]).empty?
46
+
47
+ if parts[:offset] || parts[:seconds]
48
+ return datetime.new_start(Date::GREGORIAN).in_time_zone(@output_zone)
49
+ end
50
+
51
+ @input_zone.local(
52
+ datetime.year, datetime.month, datetime.day,
53
+ datetime.hour, datetime.minute, datetime.second + datetime.sec_fraction
54
+ ).in_time_zone(@output_zone)
55
+ rescue ArgumentError, TypeError => error
56
+ raise ParseError, error.message
57
+ end
58
+ end
59
+
60
+ private_constant :Parser
61
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LesliDate
4
- VERSION = "1.0.0"
5
- BUILD = "1771715352"
4
+ VERSION = "1.1.1"
5
+ BUILD = "1790534691"
6
6
  end