timet 1.6.1.1 → 1.6.3
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 +4 -4
- data/.qlty/qlty.toml +0 -0
- data/.reek.yml +17 -1
- data/.rubocop.yml +6 -1
- data/CHANGELOG.md +37 -4
- data/README.md +40 -24
- data/lib/timet/application.rb +3 -3
- data/lib/timet/database.rb +32 -25
- data/lib/timet/database_syncer.rb +93 -131
- data/lib/timet/discord_notifier.rb +3 -1
- data/lib/timet/s3_supabase.rb +74 -131
- data/lib/timet/table.rb +0 -4
- data/lib/timet/tag_distribution.rb +181 -160
- data/lib/timet/time_helper.rb +18 -3
- data/lib/timet/time_report.rb +17 -3
- data/lib/timet/utils.rb +8 -8
- data/lib/timet/validation_editor.rb +303 -0
- data/lib/timet/version.rb +2 -2
- metadata +6 -9
- data/lib/timet/item_data_helper.rb +0 -59
- data/lib/timet/time_report_helper.rb +0 -36
- data/lib/timet/time_update_helper.rb +0 -75
- data/lib/timet/time_validation_helper.rb +0 -175
- data/lib/timet/validation_edit_helper.rb +0 -160
|
@@ -1,189 +1,210 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# Helper module for formatting tag distribution displays
|
|
3
4
|
require_relative 'time_statistics'
|
|
4
|
-
require_relative 'color_codes'
|
|
5
|
+
require_relative 'color_codes'
|
|
6
|
+
|
|
5
7
|
module Timet
|
|
6
|
-
#
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
module TagDistribution
|
|
10
|
-
MAX_BAR_LENGTH = 70
|
|
11
|
-
BLOCK_CHAR = '▅'
|
|
12
|
-
TAG_SIZE = 12
|
|
8
|
+
# Helper module for formatting tag distribution displays
|
|
9
|
+
module TagDistributionFormatting
|
|
10
|
+
module_function
|
|
13
11
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
# @example
|
|
21
|
-
# duration_by_tag = { "timet" => 3600, "nextjs" => 1800 }
|
|
22
|
-
# colors = { "timet" => "\e[31m", "nextjs" => "\e[32m" }
|
|
23
|
-
# Formatter.format_tag_distribution(duration_by_tag, colors)
|
|
24
|
-
# # Output:
|
|
25
|
-
# # \e[31m timet: 66.67% ==================== \e[0m
|
|
26
|
-
# # \e[32m nextjs: 33.33% ========== \e[0m
|
|
27
|
-
def tag_distribution(colors)
|
|
28
|
-
time_stats = TimeStatistics.new(@items)
|
|
29
|
-
total = time_stats.total_duration
|
|
12
|
+
def calculate_value_and_bar_length(duration, total)
|
|
13
|
+
value = duration.to_f / total
|
|
14
|
+
percentage_value = (value * 100).round(1)
|
|
15
|
+
bar_length = (value * TagDistribution::MAX_BAR_LENGTH).round
|
|
16
|
+
[percentage_value, bar_length]
|
|
17
|
+
end
|
|
30
18
|
|
|
31
|
-
|
|
19
|
+
def generate_horizontal_bar(bar_length, color_index)
|
|
20
|
+
(TagDistribution::BLOCK_CHAR * bar_length).to_s.color(color_index + 1)
|
|
21
|
+
end
|
|
32
22
|
|
|
33
|
-
|
|
23
|
+
def generate_stats(tag, time_stats)
|
|
24
|
+
total_hours = (time_stats.total_duration_by_tag[tag] / 3600.0).round(1)
|
|
25
|
+
avg_minutes = (time_stats.average_by_tag[tag] / 60.0).round(1)
|
|
26
|
+
sd_minutes = (time_stats.standard_deviation_by_tag[tag] / 60).round(1)
|
|
27
|
+
"T: #{total_hours}h, AVG: #{avg_minutes}min SD: #{sd_minutes}min".gray
|
|
34
28
|
end
|
|
35
29
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
print_footer
|
|
30
|
+
def introduction(total)
|
|
31
|
+
total_hours = (total / 3600.0).round(1)
|
|
32
|
+
[
|
|
33
|
+
"\n---",
|
|
34
|
+
'Time Report Summary'.bold,
|
|
35
|
+
'This report provides a detailed breakdown of time spent ' \
|
|
36
|
+
"across various categories, totaling #{"#{total_hours}h".bold} of tracked work.".white,
|
|
37
|
+
"\n"
|
|
38
|
+
]
|
|
46
39
|
end
|
|
47
40
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
puts '-' * 45
|
|
53
|
-
puts 'T:'.rjust(4).red + 'The total duration'.gray
|
|
54
|
-
puts 'AVG:'.rjust(4).red + 'The average duration'.gray
|
|
55
|
-
puts 'SD:'.rjust(4).red + 'The standard deviation of the durations'.gray
|
|
41
|
+
def build_duration_part(duration)
|
|
42
|
+
total_hours = (duration / 3600.0).round(1)
|
|
43
|
+
" The cumulative time spent was #{"#{total_hours}h".bold}, " \
|
|
44
|
+
'indicating the overall effort dedicated to this area.'
|
|
56
45
|
end
|
|
57
46
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
47
|
+
def build_average_part(metrics)
|
|
48
|
+
" On average, each task took #{"#{metrics[:avg_minutes]}min".bold}, " \
|
|
49
|
+
'which helps in understanding the typical time commitment per task.'
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def high_sd_message(sd_min)
|
|
53
|
+
" A high standard deviation of #{"#{sd_min}min".bold} " \
|
|
54
|
+
'relative to the average suggests significant variability in task durations. ' \
|
|
55
|
+
'This could imply inconsistent task definitions, varying complexity, ' \
|
|
56
|
+
'or frequent interruptions.'.red
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def moderate_sd_message(sd_min)
|
|
60
|
+
" A moderate standard deviation of #{"#{sd_min}min".bold} " \
|
|
61
|
+
'indicates some variation in task durations.'.blue
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def low_sd_message(sd_min)
|
|
65
|
+
" A low standard deviation of #{"#{sd_min}min".bold} " \
|
|
66
|
+
'suggests that task durations were quite consistent and predictable.'.green
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def build_major_summary(major_categories)
|
|
70
|
+
total_percentage = major_categories.sum(&:last)
|
|
71
|
+
category_names = major_categories.map { |c, _| "'#{c.capitalize}'" }.join(' and ')
|
|
72
|
+
["\nTogether, #{category_names} dominate the time spent, " \
|
|
73
|
+
"accounting for nearly #{"#{total_percentage.round}%".bold} of the total.".white]
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The TagDistribution module provides functionality to format and display
|
|
78
|
+
# the distribution of tags based on their durations.
|
|
79
|
+
module TagDistribution
|
|
80
|
+
MAX_BAR_LENGTH = 70
|
|
81
|
+
BLOCK_CHAR = '▅'
|
|
82
|
+
TAG_SIZE = 12
|
|
83
|
+
|
|
84
|
+
# Context object for tag distribution formatting
|
|
85
|
+
Context = Struct.new(:time_stats, :total, :colors) do
|
|
86
|
+
include TagDistributionFormatting
|
|
87
|
+
|
|
88
|
+
# Formats the overall average and standard deviation from the time statistics.
|
|
89
|
+
# @return [Hash] A hash with :avg and :sd keys, values in minutes rounded to 1 decimal place.
|
|
90
|
+
def format_avg
|
|
91
|
+
time_stats.totals.slice(:avg, :sd).transform_values { |val| (val / 60.0).round(1) }
|
|
92
|
+
end
|
|
103
93
|
|
|
104
|
-
|
|
94
|
+
# Formats the total duration in hours.
|
|
95
|
+
# @return [Float] Total duration in hours rounded to 1 decimal place.
|
|
96
|
+
def format_total_hours
|
|
97
|
+
(total / 3600.0).round(1)
|
|
105
98
|
end
|
|
106
99
|
|
|
107
|
-
#
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
100
|
+
# Calculates metrics for a specific tag.
|
|
101
|
+
# @param tag [String] The tag name.
|
|
102
|
+
# @param duration [Numeric] The duration for the tag.
|
|
103
|
+
# @return [Hash] A hash of metrics for the tag.
|
|
104
|
+
def tag_metrics(tag, duration)
|
|
105
|
+
stats = time_stats
|
|
106
|
+
value = duration.to_f / total
|
|
107
|
+
avg_sec = stats.average_by_tag[tag]
|
|
108
|
+
sd_sec = stats.standard_deviation_by_tag[tag]
|
|
109
|
+
|
|
110
|
+
{
|
|
111
|
+
tag: tag, duration: duration, percentage: (value * 100).round(1),
|
|
112
|
+
avg_minutes: (avg_sec / 60.0).round(1), sd_minutes: (sd_sec / 60.0).round(1),
|
|
113
|
+
avg_seconds: avg_sec, sd_seconds: sd_sec, value: value
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def category_breakdown
|
|
118
|
+
time_stats.sorted_duration_by_tag.each_with_object(['Category Breakdown'.bold]) do |(tag, duration), parts|
|
|
119
|
+
parts << build_category_explanation(tag, duration).white
|
|
118
120
|
end
|
|
119
121
|
end
|
|
120
|
-
puts explanations.join("\n")
|
|
121
|
-
end
|
|
122
122
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
123
|
+
def build_category_explanation(tag, duration)
|
|
124
|
+
metrics = tag_metrics(tag, duration)
|
|
125
|
+
[
|
|
126
|
+
"#{tag.capitalize.to_s.bold}:",
|
|
127
|
+
" This category consumed #{"#{metrics[:percentage]}%".bold} of the total tracked time.",
|
|
128
|
+
build_duration_part(duration), build_average_part(metrics),
|
|
129
|
+
sd_variation_message(metrics)
|
|
130
|
+
].join
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def sd_variation_message(metrics)
|
|
134
|
+
sd_min = metrics[:sd_minutes]
|
|
135
|
+
avg_hour = metrics[:avg_minutes] / 60.0
|
|
136
|
+
return high_sd_message(sd_min) if sd_min > avg_hour * 0.5
|
|
137
|
+
return moderate_sd_message(sd_min) if sd_min > avg_hour * 0.2
|
|
135
138
|
|
|
136
|
-
|
|
137
|
-
#
|
|
138
|
-
# @param time_stats [Object] An object containing the time statistics, including sorted durations by tag.
|
|
139
|
-
# @param total [Numeric] The total duration of all tags combined.
|
|
140
|
-
# @param colors [Object] An object containing color formatting methods.
|
|
141
|
-
# @return [void] This method outputs the detailed tag information to the standard output.
|
|
142
|
-
def print_tags_info(time_stats, total, colors)
|
|
143
|
-
time_stats.sorted_duration_by_tag.each do |tag, duration|
|
|
144
|
-
value, bar_length = calculate_value_and_bar_length(duration, total)
|
|
145
|
-
horizontal_bar = generate_horizontal_bar(bar_length, colors[tag])
|
|
146
|
-
formatted_tag = tag[0...TAG_SIZE].rjust(TAG_SIZE)
|
|
147
|
-
stats = generate_stats(tag, time_stats)
|
|
148
|
-
|
|
149
|
-
puts "#{formatted_tag}: #{value.to_s.rjust(5)}% #{horizontal_bar} [#{stats}]"
|
|
139
|
+
low_sd_message(sd_min)
|
|
150
140
|
end
|
|
151
|
-
end
|
|
152
141
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
142
|
+
def build_sorted_categories
|
|
143
|
+
time_stats.sorted_duration_by_tag.map do |tag, duration|
|
|
144
|
+
[tag, tag_metrics(tag, duration)[:percentage]]
|
|
145
|
+
end.sort_by(&:last).reverse
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def overall_summary
|
|
149
|
+
return [] unless time_stats.sorted_duration_by_tag.any?
|
|
150
|
+
|
|
151
|
+
major_categories = build_sorted_categories.select { |_, pct| pct > 10 }
|
|
152
|
+
return [] if major_categories.size <= 1
|
|
153
|
+
|
|
154
|
+
build_major_summary(major_categories)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def print_summary
|
|
158
|
+
f_avg = format_avg
|
|
159
|
+
summary = "#{' ' * TAG_SIZE} #{'Summary'.underline}: "
|
|
160
|
+
summary += "[T: #{format_total_hours}, AVG: #{f_avg[:avg]}min SD: #{f_avg[:sd]}min]".white
|
|
161
|
+
puts summary
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def print_explanation
|
|
165
|
+
parts = []
|
|
166
|
+
parts << introduction(total)
|
|
167
|
+
parts << category_breakdown
|
|
168
|
+
parts << overall_summary
|
|
169
|
+
puts parts.flatten.join("\n")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def print_tags_info
|
|
173
|
+
stats = time_stats
|
|
174
|
+
stats.sorted_duration_by_tag.each do |tag, duration|
|
|
175
|
+
value, bar_length = calculate_value_and_bar_length(duration, total)
|
|
176
|
+
horizontal_bar = generate_horizontal_bar(bar_length, colors[tag])
|
|
177
|
+
formatted_tag = tag[0...TAG_SIZE].rjust(TAG_SIZE)
|
|
178
|
+
tag_stats = generate_stats(tag, stats)
|
|
179
|
+
|
|
180
|
+
puts "#{formatted_tag}: #{value.to_s.rjust(5)}% #{horizontal_bar} [#{tag_stats}]"
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def render
|
|
185
|
+
print_summary
|
|
186
|
+
print_tags_info
|
|
187
|
+
end
|
|
160
188
|
end
|
|
161
189
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
190
|
+
include TagDistributionFormatting
|
|
191
|
+
|
|
192
|
+
def tag_distribution(colors)
|
|
193
|
+
time_stats = TimeStatistics.new(@items)
|
|
194
|
+
total = time_stats.total_duration
|
|
195
|
+
|
|
196
|
+
return unless total.positive?
|
|
197
|
+
|
|
198
|
+
ctx = Context.new(time_stats, total, colors)
|
|
199
|
+
ctx.render
|
|
200
|
+
print_footer
|
|
172
201
|
end
|
|
173
202
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
#
|
|
180
|
-
# @example
|
|
181
|
-
# calculate_value_and_bar_length(50, 100, 2) #=> [50.0, 25]
|
|
182
|
-
def calculate_value_and_bar_length(duration, total)
|
|
183
|
-
value = duration.to_f / total
|
|
184
|
-
percentage_value = (value * 100).round(1)
|
|
185
|
-
bar_length = (value * MAX_BAR_LENGTH).round
|
|
186
|
-
[percentage_value, bar_length]
|
|
203
|
+
def print_footer
|
|
204
|
+
puts '-' * 45
|
|
205
|
+
puts 'T:'.rjust(4).red + 'The total duration'.gray
|
|
206
|
+
puts 'AVG:'.rjust(4).red + 'The average duration'.gray
|
|
207
|
+
puts 'SD:'.rjust(4).red + 'The standard deviation of the durations'.gray
|
|
187
208
|
end
|
|
188
209
|
end
|
|
189
210
|
end
|
data/lib/timet/time_helper.rb
CHANGED
|
@@ -278,9 +278,24 @@ module Timet
|
|
|
278
278
|
def self.update_time_field(item, field, new_time)
|
|
279
279
|
field_index = Timet::Application::FIELD_INDEX[field]
|
|
280
280
|
timestamp = item[field_index]
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
281
|
+
base_time = Time.at(timestamp || item[1])
|
|
282
|
+
|
|
283
|
+
# Extract hours, minutes, and seconds from the new_time string
|
|
284
|
+
# Supports format "HH:MM:SS" or "HH:MM"
|
|
285
|
+
parts = new_time.split(':').map(&:to_i)
|
|
286
|
+
hours = parts[0]
|
|
287
|
+
minutes = parts[1]
|
|
288
|
+
seconds = parts[2] || 0
|
|
289
|
+
|
|
290
|
+
Time.new(
|
|
291
|
+
base_time.year,
|
|
292
|
+
base_time.month,
|
|
293
|
+
base_time.day,
|
|
294
|
+
hours,
|
|
295
|
+
minutes,
|
|
296
|
+
seconds,
|
|
297
|
+
base_time.utc_offset
|
|
298
|
+
)
|
|
284
299
|
end
|
|
285
300
|
end
|
|
286
301
|
end
|
data/lib/timet/time_report.rb
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
require 'date'
|
|
4
4
|
require 'csv'
|
|
5
5
|
require 'icalendar'
|
|
6
|
-
require_relative 'time_report_helper'
|
|
7
6
|
require_relative 'table'
|
|
8
7
|
require_relative 'time_block_chart'
|
|
9
8
|
require_relative 'tag_distribution'
|
|
@@ -13,7 +12,6 @@ module Timet
|
|
|
13
12
|
# entries. It allows filtering the report by time periods and displays
|
|
14
13
|
# a formatted table with the relevant information.
|
|
15
14
|
class TimeReport
|
|
16
|
-
include TimeReportHelper
|
|
17
15
|
include TagDistribution
|
|
18
16
|
|
|
19
17
|
# Provides access to the database instance.
|
|
@@ -103,7 +101,10 @@ module Timet
|
|
|
103
101
|
def print_tag_explanation_report
|
|
104
102
|
time_stats = TimeStatistics.new(@items)
|
|
105
103
|
total = time_stats.total_duration
|
|
106
|
-
|
|
104
|
+
return unless total.positive?
|
|
105
|
+
|
|
106
|
+
ctx = TagDistribution::Context.new(time_stats, total, nil)
|
|
107
|
+
ctx.print_explanation
|
|
107
108
|
end
|
|
108
109
|
|
|
109
110
|
# Displays a single row of the report.
|
|
@@ -133,6 +134,19 @@ module Timet
|
|
|
133
134
|
@table.total
|
|
134
135
|
end
|
|
135
136
|
|
|
137
|
+
def export_csv
|
|
138
|
+
file_name = "#{csv_filename}.csv"
|
|
139
|
+
write_csv(file_name)
|
|
140
|
+
puts "The #{file_name} has been exported."
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def export_icalendar
|
|
144
|
+
file_name = "#{ics_filename}.ics"
|
|
145
|
+
cal = Timet::Utils.add_events(items)
|
|
146
|
+
File.write(file_name, cal.to_ical)
|
|
147
|
+
puts "The #{file_name} has been generated."
|
|
148
|
+
end
|
|
149
|
+
|
|
136
150
|
private
|
|
137
151
|
|
|
138
152
|
# Writes the items to a CSV file.
|
data/lib/timet/utils.rb
CHANGED
|
@@ -23,12 +23,12 @@ module Timet
|
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
# Converts a timestamp to a
|
|
26
|
+
# Converts a timestamp to a Time object.
|
|
27
27
|
#
|
|
28
28
|
# @param timestamp [Integer] the timestamp to convert
|
|
29
|
-
# @return [
|
|
30
|
-
def self.
|
|
31
|
-
Time.at(timestamp)
|
|
29
|
+
# @return [Time] the converted Time object
|
|
30
|
+
def self.convert_to_time(timestamp)
|
|
31
|
+
Time.at(timestamp)
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
# Provides predefined date ranges for filtering.
|
|
@@ -82,8 +82,8 @@ module Timet
|
|
|
82
82
|
#
|
|
83
83
|
# @note The method validates the date format for single dates and date ranges.
|
|
84
84
|
def self.valid_date_format?(date_string)
|
|
85
|
-
date_format_single =
|
|
86
|
-
date_format_range =
|
|
85
|
+
date_format_single = /\A\d{4}-\d{2}-\d{2}\z/
|
|
86
|
+
date_format_range = /\A\d{4}-\d{2}-\d{2}\.\.\d{4}-\d{2}-\d{2}\z/
|
|
87
87
|
|
|
88
88
|
date_string.match?(date_format_single) || date_string.match?(date_format_range)
|
|
89
89
|
end
|
|
@@ -93,8 +93,8 @@ module Timet
|
|
|
93
93
|
# @param event [Icalendar::Event] the event object
|
|
94
94
|
# @param item [Array] the item containing event details
|
|
95
95
|
def self.assign_event_attributes(event, item)
|
|
96
|
-
dtstart =
|
|
97
|
-
dtend =
|
|
96
|
+
dtstart = convert_to_time(item[1])
|
|
97
|
+
dtend = convert_to_time(item[2] || TimeHelper.current_timestamp)
|
|
98
98
|
|
|
99
99
|
event.dtstart = dtstart
|
|
100
100
|
event.dtend = dtend
|