snitcher_reports 0.2.0
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 +7 -0
- data/.snitcher.yml.example +135 -0
- data/CHANGELOG.md +9 -0
- data/LICENSE +21 -0
- data/README.md +685 -0
- data/lib/snitcher/anomaly.rb +24 -0
- data/lib/snitcher/anomaly_context.rb +76 -0
- data/lib/snitcher/anomaly_detector.rb +32 -0
- data/lib/snitcher/anomaly_rules/base.rb +29 -0
- data/lib/snitcher/anomaly_rules/commits_without_closed_tickets.rb +22 -0
- data/lib/snitcher/anomaly_rules/estimate_overrun.rb +37 -0
- data/lib/snitcher/anomaly_rules/excessive_meeting_time.rb +43 -0
- data/lib/snitcher/anomaly_rules/inactive_working_days.rb +32 -0
- data/lib/snitcher/anomaly_rules/time_above_availability.rb +27 -0
- data/lib/snitcher/client.rb +177 -0
- data/lib/snitcher/configuration.rb +123 -0
- data/lib/snitcher/holiday_region_resolver.rb +123 -0
- data/lib/snitcher/jql_validator.rb +44 -0
- data/lib/snitcher/meetings_aggregator.rb +38 -0
- data/lib/snitcher/orchestrator.rb +227 -0
- data/lib/snitcher/presenters/html.rb +87 -0
- data/lib/snitcher/presenters/markdown.rb +140 -0
- data/lib/snitcher/presenters/presenter.rb +90 -0
- data/lib/snitcher/report.rb +112 -0
- data/lib/snitcher/report_service.rb +128 -0
- data/lib/snitcher/report_writer.rb +74 -0
- data/lib/snitcher/sources/clickup/archived_lists.rb +174 -0
- data/lib/snitcher/sources/clickup/working_hours_filter.rb +88 -0
- data/lib/snitcher/sources/clickup.rb +467 -0
- data/lib/snitcher/sources/git/command_runner.rb +198 -0
- data/lib/snitcher/sources/git.rb +347 -0
- data/lib/snitcher/sources/google_calendar.rb +328 -0
- data/lib/snitcher/sources/jira/client.rb +346 -0
- data/lib/snitcher/sources/jira/status_categories.rb +60 -0
- data/lib/snitcher/sources/jira.rb +523 -0
- data/lib/snitcher/sources/linear.rb +257 -0
- data/lib/snitcher/sources/source.rb +140 -0
- data/lib/snitcher/templates/report.html.erb +564 -0
- data/lib/snitcher/templates/snitcher-logo.png +0 -0
- data/lib/snitcher/time_formatter.rb +27 -0
- data/lib/snitcher/validators.rb +59 -0
- data/lib/snitcher/version.rb +5 -0
- data/lib/snitcher/work_item.rb +75 -0
- data/lib/snitcher/working_hours_calculator.rb +205 -0
- data/lib/snitcher/working_hours_config.rb +208 -0
- data/lib/snitcher.rb +39 -0
- metadata +250 -0
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Progress Report</title>
|
|
7
|
+
<style>
|
|
8
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
9
|
+
|
|
10
|
+
:root {
|
|
11
|
+
--color-ink: #1a1a2e;
|
|
12
|
+
--color-accent: #8b7fb5;
|
|
13
|
+
--color-border: #e5e7eb;
|
|
14
|
+
--color-border-subtle: #f3f4f6;
|
|
15
|
+
--color-secondary: #6b7280;
|
|
16
|
+
--color-muted: #9ca3af;
|
|
17
|
+
--color-body-text: #374151;
|
|
18
|
+
--color-white: #ffffff;
|
|
19
|
+
--color-link: #7c6af7;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
body {
|
|
23
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
|
24
|
+
background: #f5f0eb;
|
|
25
|
+
color: var(--color-ink);
|
|
26
|
+
padding: 48px;
|
|
27
|
+
max-width: 1100px;
|
|
28
|
+
margin: 0 auto;
|
|
29
|
+
line-height: 1.5;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* ── Header ── */
|
|
33
|
+
.report-header {
|
|
34
|
+
display: flex;
|
|
35
|
+
justify-content: space-between;
|
|
36
|
+
align-items: flex-start;
|
|
37
|
+
margin-bottom: 32px;
|
|
38
|
+
}
|
|
39
|
+
.report-label {
|
|
40
|
+
font-size: 0.75rem;
|
|
41
|
+
font-weight: 700;
|
|
42
|
+
letter-spacing: 0.12em;
|
|
43
|
+
text-transform: uppercase;
|
|
44
|
+
color: #c0392b;
|
|
45
|
+
margin-bottom: 12px;
|
|
46
|
+
}
|
|
47
|
+
.report-meta-right {
|
|
48
|
+
text-align: right;
|
|
49
|
+
color: var(--color-secondary);
|
|
50
|
+
font-size: 0.85rem;
|
|
51
|
+
}
|
|
52
|
+
.report-meta-right .logo {
|
|
53
|
+
display: block;
|
|
54
|
+
height: 80px;
|
|
55
|
+
width: auto;
|
|
56
|
+
margin-left: auto;
|
|
57
|
+
mix-blend-mode: multiply;
|
|
58
|
+
}
|
|
59
|
+
.report-title {
|
|
60
|
+
font-family: Georgia, 'Times New Roman', Times, serif;
|
|
61
|
+
font-size: 2rem;
|
|
62
|
+
font-weight: 700;
|
|
63
|
+
color: var(--color-ink);
|
|
64
|
+
line-height: 1.1;
|
|
65
|
+
margin-bottom: 4px;
|
|
66
|
+
word-break: break-all;
|
|
67
|
+
}
|
|
68
|
+
.report-period {
|
|
69
|
+
font-family: Georgia, 'Times New Roman', Times, serif;
|
|
70
|
+
font-size: 1.1rem;
|
|
71
|
+
font-weight: 600;
|
|
72
|
+
color: var(--color-ink);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
hr.divider {
|
|
76
|
+
border: none;
|
|
77
|
+
border-top: 3px solid var(--color-ink);
|
|
78
|
+
margin-bottom: 40px;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* ── Narrative overview ── */
|
|
82
|
+
.narrative {
|
|
83
|
+
font-size: 1rem;
|
|
84
|
+
line-height: 1.6;
|
|
85
|
+
color: var(--color-body-text);
|
|
86
|
+
margin: -24px 0 1.5rem 0;
|
|
87
|
+
padding: 1rem 1.25rem;
|
|
88
|
+
background: var(--color-border-subtle);
|
|
89
|
+
border-left: 3px solid var(--color-link);
|
|
90
|
+
border-radius: 0 6px 6px 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* ── Summary cards ── */
|
|
94
|
+
.stats {
|
|
95
|
+
display: flex;
|
|
96
|
+
gap: 16px;
|
|
97
|
+
margin-bottom: 48px;
|
|
98
|
+
}
|
|
99
|
+
.stat-card {
|
|
100
|
+
flex: 1;
|
|
101
|
+
background: var(--color-white);
|
|
102
|
+
border: 1px solid var(--color-border);
|
|
103
|
+
border-radius: 8px;
|
|
104
|
+
padding: 20px 24px;
|
|
105
|
+
text-align: center;
|
|
106
|
+
}
|
|
107
|
+
.stat-value {
|
|
108
|
+
font-family: Georgia, 'Times New Roman', Times, serif;
|
|
109
|
+
font-size: 2.5rem;
|
|
110
|
+
font-weight: 700;
|
|
111
|
+
color: var(--color-ink);
|
|
112
|
+
line-height: 1;
|
|
113
|
+
margin-bottom: 8px;
|
|
114
|
+
}
|
|
115
|
+
.stat-label {
|
|
116
|
+
font-size: 0.7rem;
|
|
117
|
+
font-weight: 600;
|
|
118
|
+
letter-spacing: 0.1em;
|
|
119
|
+
text-transform: uppercase;
|
|
120
|
+
color: var(--color-accent);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* ── Source sections ── */
|
|
124
|
+
.source-section {
|
|
125
|
+
margin-bottom: 48px;
|
|
126
|
+
}
|
|
127
|
+
h2 {
|
|
128
|
+
font-size: 1rem;
|
|
129
|
+
font-weight: 600;
|
|
130
|
+
letter-spacing: 0.05em;
|
|
131
|
+
text-transform: uppercase;
|
|
132
|
+
color: var(--color-secondary);
|
|
133
|
+
margin-bottom: 12px;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/* ── Table ── */
|
|
137
|
+
table {
|
|
138
|
+
width: 100%;
|
|
139
|
+
border-collapse: collapse;
|
|
140
|
+
}
|
|
141
|
+
thead th {
|
|
142
|
+
text-align: left;
|
|
143
|
+
font-size: 0.7rem;
|
|
144
|
+
font-weight: 700;
|
|
145
|
+
letter-spacing: 0.1em;
|
|
146
|
+
text-transform: uppercase;
|
|
147
|
+
color: var(--color-accent);
|
|
148
|
+
padding: 12px 16px;
|
|
149
|
+
border-bottom: 2px solid var(--color-accent);
|
|
150
|
+
}
|
|
151
|
+
tbody tr {
|
|
152
|
+
border-bottom: 1px solid var(--color-border);
|
|
153
|
+
}
|
|
154
|
+
tbody tr:last-child {
|
|
155
|
+
border-bottom: none;
|
|
156
|
+
}
|
|
157
|
+
tbody td {
|
|
158
|
+
padding: 16px;
|
|
159
|
+
font-size: 0.95rem;
|
|
160
|
+
vertical-align: middle;
|
|
161
|
+
}
|
|
162
|
+
.col-id {
|
|
163
|
+
width: 140px;
|
|
164
|
+
white-space: nowrap;
|
|
165
|
+
}
|
|
166
|
+
.col-time {
|
|
167
|
+
width: 110px;
|
|
168
|
+
white-space: nowrap;
|
|
169
|
+
}
|
|
170
|
+
.col-completed {
|
|
171
|
+
width: 120px;
|
|
172
|
+
white-space: nowrap;
|
|
173
|
+
}
|
|
174
|
+
td.col-completed {
|
|
175
|
+
font-family: 'SF Mono', 'Fira Code', 'Fira Mono', Menlo, Consolas, monospace;
|
|
176
|
+
font-size: 0.85rem;
|
|
177
|
+
color: var(--color-body-text);
|
|
178
|
+
}
|
|
179
|
+
a {
|
|
180
|
+
color: var(--color-link);
|
|
181
|
+
text-decoration: none;
|
|
182
|
+
}
|
|
183
|
+
a:hover { text-decoration: underline; }
|
|
184
|
+
|
|
185
|
+
.empty-state {
|
|
186
|
+
text-align: center;
|
|
187
|
+
padding: 60px 20px;
|
|
188
|
+
color: var(--color-muted);
|
|
189
|
+
font-style: italic;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
footer {
|
|
193
|
+
margin-top: 48px;
|
|
194
|
+
padding-top: 16px;
|
|
195
|
+
border-top: 1px solid var(--color-border);
|
|
196
|
+
text-align: center;
|
|
197
|
+
font-size: 0.8rem;
|
|
198
|
+
color: var(--color-muted);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/* ── Table summary row ── */
|
|
202
|
+
tfoot td {
|
|
203
|
+
padding: 12px 16px;
|
|
204
|
+
font-size: 0.95rem;
|
|
205
|
+
font-weight: 600;
|
|
206
|
+
color: var(--color-secondary);
|
|
207
|
+
border-top: 2px solid var(--color-border);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/* ── Print buttons ── */
|
|
211
|
+
.btn-print {
|
|
212
|
+
display: inline-flex;
|
|
213
|
+
align-items: center;
|
|
214
|
+
gap: 6px;
|
|
215
|
+
padding: 10px 20px;
|
|
216
|
+
background: var(--color-ink);
|
|
217
|
+
color: var(--color-white);
|
|
218
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
|
219
|
+
font-size: 0.85rem;
|
|
220
|
+
font-weight: 600;
|
|
221
|
+
border: none;
|
|
222
|
+
border-radius: 6px;
|
|
223
|
+
cursor: pointer;
|
|
224
|
+
white-space: nowrap;
|
|
225
|
+
align-self: center;
|
|
226
|
+
}
|
|
227
|
+
.btn-print:hover { opacity: 0.85; }
|
|
228
|
+
|
|
229
|
+
.btn-print-footer {
|
|
230
|
+
background: none;
|
|
231
|
+
border: none;
|
|
232
|
+
color: var(--color-muted);
|
|
233
|
+
font-size: 0.8rem;
|
|
234
|
+
cursor: pointer;
|
|
235
|
+
padding: 0 0 0 8px;
|
|
236
|
+
text-decoration: underline;
|
|
237
|
+
text-underline-offset: 2px;
|
|
238
|
+
}
|
|
239
|
+
.btn-print-footer:hover { color: var(--color-secondary); }
|
|
240
|
+
|
|
241
|
+
/* ── Print ── */
|
|
242
|
+
@page { margin: 1.5cm; }
|
|
243
|
+
|
|
244
|
+
@media print {
|
|
245
|
+
.no-print { display: none !important; }
|
|
246
|
+
|
|
247
|
+
body {
|
|
248
|
+
background: var(--color-white);
|
|
249
|
+
padding: 0;
|
|
250
|
+
max-width: none;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
.stats { break-inside: avoid; break-after: avoid; }
|
|
254
|
+
tbody tr { break-inside: avoid; }
|
|
255
|
+
tfoot { display: table-row-group; }
|
|
256
|
+
|
|
257
|
+
* { print-color-adjust: exact; -webkit-print-color-adjust: exact; }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/* ── Mobile (card layout) ── */
|
|
261
|
+
@media (max-width: 600px) {
|
|
262
|
+
body {
|
|
263
|
+
padding: 16px;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
.stats {
|
|
267
|
+
gap: 10px;
|
|
268
|
+
margin-bottom: 32px;
|
|
269
|
+
}
|
|
270
|
+
.stat-card {
|
|
271
|
+
padding: 14px 16px;
|
|
272
|
+
}
|
|
273
|
+
.stat-value {
|
|
274
|
+
font-size: 2rem;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
thead {
|
|
278
|
+
display: none;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
table, tbody, tr, td {
|
|
282
|
+
display: block;
|
|
283
|
+
width: 100%;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
tbody tr {
|
|
287
|
+
background: var(--color-white);
|
|
288
|
+
border: 1px solid var(--color-border);
|
|
289
|
+
border-radius: 8px;
|
|
290
|
+
margin-bottom: 12px;
|
|
291
|
+
padding: 12px 16px;
|
|
292
|
+
}
|
|
293
|
+
tbody tr:last-child {
|
|
294
|
+
border-bottom: 1px solid var(--color-border);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
tbody td {
|
|
298
|
+
display: flex;
|
|
299
|
+
justify-content: space-between;
|
|
300
|
+
align-items: flex-start;
|
|
301
|
+
gap: 12px;
|
|
302
|
+
padding: 6px 0;
|
|
303
|
+
font-size: 0.9rem;
|
|
304
|
+
border: none;
|
|
305
|
+
}
|
|
306
|
+
tbody td:not(:last-child) {
|
|
307
|
+
border-bottom: 1px solid var(--color-border-subtle);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
tbody td::before {
|
|
311
|
+
content: attr(data-label);
|
|
312
|
+
flex-shrink: 0;
|
|
313
|
+
font-size: 0.65rem;
|
|
314
|
+
font-weight: 700;
|
|
315
|
+
letter-spacing: 0.1em;
|
|
316
|
+
text-transform: uppercase;
|
|
317
|
+
color: var(--color-accent);
|
|
318
|
+
padding-top: 3px;
|
|
319
|
+
min-width: 90px;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
tbody td[data-label="Title"] {
|
|
323
|
+
flex-direction: column;
|
|
324
|
+
gap: 4px;
|
|
325
|
+
}
|
|
326
|
+
tbody td[data-label="Title"]::before {
|
|
327
|
+
min-width: unset;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.col-id, .col-time, .col-completed {
|
|
331
|
+
width: auto;
|
|
332
|
+
white-space: normal;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
tfoot, tfoot tr {
|
|
336
|
+
display: flex;
|
|
337
|
+
width: 100%;
|
|
338
|
+
}
|
|
339
|
+
tfoot tr {
|
|
340
|
+
justify-content: space-between;
|
|
341
|
+
align-items: center;
|
|
342
|
+
background: none;
|
|
343
|
+
border: none;
|
|
344
|
+
border-top: 2px solid #e5e7eb;
|
|
345
|
+
padding: 6px 16px;
|
|
346
|
+
margin-bottom: 0;
|
|
347
|
+
}
|
|
348
|
+
tfoot td {
|
|
349
|
+
display: block;
|
|
350
|
+
border: none;
|
|
351
|
+
padding: 6px 0;
|
|
352
|
+
}
|
|
353
|
+
tfoot td:empty {
|
|
354
|
+
display: none;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
.report-header {
|
|
358
|
+
flex-direction: column-reverse;
|
|
359
|
+
align-items: center;
|
|
360
|
+
gap: 12px;
|
|
361
|
+
margin-bottom: 16px;
|
|
362
|
+
}
|
|
363
|
+
hr.divider {
|
|
364
|
+
margin-bottom: 24px;
|
|
365
|
+
}
|
|
366
|
+
.report-header-left {
|
|
367
|
+
width: 100%;
|
|
368
|
+
text-align: center;
|
|
369
|
+
}
|
|
370
|
+
.report-meta-right {
|
|
371
|
+
text-align: center;
|
|
372
|
+
}
|
|
373
|
+
.report-meta-right .logo {
|
|
374
|
+
height: 48px;
|
|
375
|
+
margin-left: auto;
|
|
376
|
+
margin-right: auto;
|
|
377
|
+
margin-bottom: 8px;
|
|
378
|
+
}
|
|
379
|
+
.report-title {
|
|
380
|
+
font-size: 1.05rem;
|
|
381
|
+
word-break: break-word;
|
|
382
|
+
}
|
|
383
|
+
.report-period {
|
|
384
|
+
font-size: 0.95rem;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
</style>
|
|
388
|
+
</head>
|
|
389
|
+
<body>
|
|
390
|
+
|
|
391
|
+
<div class="report-header">
|
|
392
|
+
<div class="report-header-left">
|
|
393
|
+
<p class="report-label">Progress Report</p>
|
|
394
|
+
<% if config[:user_email] %>
|
|
395
|
+
<p class="report-title"><%= ERB::Util.html_escape(config[:user_email]) %></p>
|
|
396
|
+
<% end %>
|
|
397
|
+
<% if config[:since] && config[:until_time] %>
|
|
398
|
+
<%
|
|
399
|
+
since_str = config[:since].strftime('%B %-d, %Y')
|
|
400
|
+
until_str = (config[:until_time] - Snitcher::TimeFormatter::SECONDS_PER_DAY).strftime('%B %-d, %Y')
|
|
401
|
+
%>
|
|
402
|
+
<p class="report-period"><%= ERB::Util.html_escape(since_str) %> – <%= ERB::Util.html_escape(until_str) %></p>
|
|
403
|
+
<% end %>
|
|
404
|
+
</div>
|
|
405
|
+
<button class="btn-print no-print" onclick="window.print()">
|
|
406
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
407
|
+
<path d="M6 9V2h12v7"/><rect x="6" y="17" width="12" height="5"/><path d="M6 13H4a2 2 0 0 0-2 2v4h4v-4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v4h4v-4a2 2 0 0 0-2-2h-2"/>
|
|
408
|
+
</svg>
|
|
409
|
+
Print to PDF
|
|
410
|
+
</button>
|
|
411
|
+
|
|
412
|
+
<div class="report-meta-right">
|
|
413
|
+
<img class="logo" src="<%= logo_data_uri %>" alt="Snitcher">
|
|
414
|
+
</div>
|
|
415
|
+
</div>
|
|
416
|
+
|
|
417
|
+
<hr class="divider">
|
|
418
|
+
|
|
419
|
+
<% if items.empty? %>
|
|
420
|
+
<div class="empty-state"><p>No work items found.</p></div>
|
|
421
|
+
<% else %>
|
|
422
|
+
<div class="stats">
|
|
423
|
+
<div class="stat-card">
|
|
424
|
+
<p class="stat-value"><%= stats[:hours_logged] %></p>
|
|
425
|
+
<p class="stat-label">Hours Logged</p>
|
|
426
|
+
</div>
|
|
427
|
+
<div class="stat-card">
|
|
428
|
+
<p class="stat-value"><%= stats[:tracker_items] %></p>
|
|
429
|
+
<p class="stat-label">Tickets Closed</p>
|
|
430
|
+
</div>
|
|
431
|
+
<div class="stat-card">
|
|
432
|
+
<p class="stat-value"><%= stats[:commits_count] %></p>
|
|
433
|
+
<p class="stat-label">Commits</p>
|
|
434
|
+
</div>
|
|
435
|
+
<% if stats[:meeting_aggregate][:event_count].positive? %>
|
|
436
|
+
<div class="stat-card">
|
|
437
|
+
<p class="stat-value"><%= stats[:meeting_hours] %></p>
|
|
438
|
+
<p class="stat-label">Meeting Hours</p>
|
|
439
|
+
</div>
|
|
440
|
+
<% end %>
|
|
441
|
+
<% if anomalies.any? %>
|
|
442
|
+
<div class="stat-card">
|
|
443
|
+
<p class="stat-value"><%= anomalies.count %></p>
|
|
444
|
+
<p class="stat-label">Potential Problems</p>
|
|
445
|
+
</div>
|
|
446
|
+
<% end %>
|
|
447
|
+
</div>
|
|
448
|
+
|
|
449
|
+
<% if (narrative = config[:narrative]) %>
|
|
450
|
+
<div class="narrative">
|
|
451
|
+
<p><%= render_narrative_html(narrative) %></p>
|
|
452
|
+
</div>
|
|
453
|
+
<% end %>
|
|
454
|
+
|
|
455
|
+
<% grouped.each do |source, source_items| %>
|
|
456
|
+
<%
|
|
457
|
+
src_seconds = source_items.sum { |i| i.time_spent_seconds || 0 }
|
|
458
|
+
src_time = src_seconds > 0 ? Snitcher::TimeFormatter.format_duration(src_seconds) : '-'
|
|
459
|
+
src_count = source_items.count
|
|
460
|
+
%>
|
|
461
|
+
<div class="source-section">
|
|
462
|
+
<h2><%= ERB::Util.html_escape(source.to_s) %></h2>
|
|
463
|
+
<table>
|
|
464
|
+
<thead>
|
|
465
|
+
<tr>
|
|
466
|
+
<th class="col-id">ID</th>
|
|
467
|
+
<th>Title</th>
|
|
468
|
+
<th class="col-time">Time Spent</th>
|
|
469
|
+
<th class="col-completed">Completed</th>
|
|
470
|
+
</tr>
|
|
471
|
+
</thead>
|
|
472
|
+
<tbody>
|
|
473
|
+
<% source_items.each do |item| %>
|
|
474
|
+
<tr>
|
|
475
|
+
<td class="col-id" data-label="ID">
|
|
476
|
+
<% if (url = safe_url(item.url)) %>
|
|
477
|
+
<a href="<%= url %>" target="_blank" rel="noopener noreferrer">
|
|
478
|
+
<%= ERB::Util.html_escape(item.external_id) %>
|
|
479
|
+
</a>
|
|
480
|
+
<% else %>
|
|
481
|
+
<%= ERB::Util.html_escape(item.external_id) %>
|
|
482
|
+
<% end %>
|
|
483
|
+
</td>
|
|
484
|
+
<td data-label="Title"><%= ERB::Util.html_escape(item.title) %></td>
|
|
485
|
+
<td class="col-time" data-label="Time Spent"><%= ERB::Util.html_escape(item.time_spent_human || '-') %></td>
|
|
486
|
+
<td class="col-completed" data-label="Completed"><%= ERB::Util.html_escape(item.completed_date || '-') %></td>
|
|
487
|
+
</tr>
|
|
488
|
+
<% end %>
|
|
489
|
+
</tbody>
|
|
490
|
+
<tfoot>
|
|
491
|
+
<tr>
|
|
492
|
+
<td colspan="2"><%= src_count %> <%= item_label(source, src_count) %></td>
|
|
493
|
+
<td class="col-time"><%= ERB::Util.html_escape(src_time) %></td>
|
|
494
|
+
<td class="col-completed"></td>
|
|
495
|
+
</tr>
|
|
496
|
+
</tfoot>
|
|
497
|
+
</table>
|
|
498
|
+
</div>
|
|
499
|
+
<% end %>
|
|
500
|
+
|
|
501
|
+
<% if stats[:meeting_aggregate][:event_count].positive? %>
|
|
502
|
+
<% agg = stats[:meeting_aggregate] %>
|
|
503
|
+
<div class="source-section">
|
|
504
|
+
<h2>Meetings</h2>
|
|
505
|
+
<table>
|
|
506
|
+
<thead>
|
|
507
|
+
<tr>
|
|
508
|
+
<th>Meeting</th>
|
|
509
|
+
<th class="col-time">Count</th>
|
|
510
|
+
<th class="col-time">Time</th>
|
|
511
|
+
</tr>
|
|
512
|
+
</thead>
|
|
513
|
+
<tbody>
|
|
514
|
+
<% agg[:rows].each do |row| %>
|
|
515
|
+
<tr>
|
|
516
|
+
<td data-label="Meeting"><%= ERB::Util.html_escape(row[:title]) %></td>
|
|
517
|
+
<td class="col-time" data-label="Count"><%= row[:count] %></td>
|
|
518
|
+
<td class="col-time" data-label="Time"><%= ERB::Util.html_escape(Snitcher::TimeFormatter.format_duration(row[:total_seconds])) %></td>
|
|
519
|
+
</tr>
|
|
520
|
+
<% end %>
|
|
521
|
+
</tbody>
|
|
522
|
+
<tfoot>
|
|
523
|
+
<tr>
|
|
524
|
+
<td><%= agg[:event_count] %> <%= agg[:event_count] == 1 ? 'event' : 'events' %></td>
|
|
525
|
+
<td class="col-time"></td>
|
|
526
|
+
<td class="col-time"><%= ERB::Util.html_escape(Snitcher::TimeFormatter.format_duration(agg[:total_seconds])) %></td>
|
|
527
|
+
</tr>
|
|
528
|
+
</tfoot>
|
|
529
|
+
</table>
|
|
530
|
+
</div>
|
|
531
|
+
<% end %>
|
|
532
|
+
|
|
533
|
+
<% end %>
|
|
534
|
+
|
|
535
|
+
<%# Outside the items.empty? branch on purpose: a period with no items is exactly when
|
|
536
|
+
the detected anomalies are the only thing worth reading. %>
|
|
537
|
+
<% if anomalies.any? %>
|
|
538
|
+
<div class="source-section">
|
|
539
|
+
<h2>Potential Problems</h2>
|
|
540
|
+
<table>
|
|
541
|
+
<thead>
|
|
542
|
+
<tr><th class="col-id">Severity</th><th>Signal</th><th>Details</th></tr>
|
|
543
|
+
</thead>
|
|
544
|
+
<tbody>
|
|
545
|
+
<% anomalies.each do |a| %>
|
|
546
|
+
<tr>
|
|
547
|
+
<td class="col-id" data-label="Severity">
|
|
548
|
+
<span style="color: <%= severity_color(a.severity) %>; font-weight: 600;"><%= ERB::Util.html_escape(a.severity.to_s) %></span>
|
|
549
|
+
</td>
|
|
550
|
+
<td data-label="Signal"><%= ERB::Util.html_escape(a.title) %></td>
|
|
551
|
+
<td data-label="Details"><%= ERB::Util.html_escape(a.description) %></td>
|
|
552
|
+
</tr>
|
|
553
|
+
<% end %>
|
|
554
|
+
</tbody>
|
|
555
|
+
</table>
|
|
556
|
+
</div>
|
|
557
|
+
<% end %>
|
|
558
|
+
|
|
559
|
+
<footer>
|
|
560
|
+
Generated on <%= render_time.strftime('%Y-%m-%d') %> at <%= render_time.strftime('%H:%M:%S') %>
|
|
561
|
+
<button class="btn-print-footer no-print" onclick="window.print()">Print to PDF</button>
|
|
562
|
+
</footer>
|
|
563
|
+
</body>
|
|
564
|
+
</html>
|
|
Binary file
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Snitcher
|
|
4
|
+
# Shared time formatting utilities.
|
|
5
|
+
# Used by WorkItem and Report to ensure consistent duration display.
|
|
6
|
+
module TimeFormatter
|
|
7
|
+
SECONDS_PER_HOUR = 3_600
|
|
8
|
+
SECONDS_PER_DAY = 86_400 # 24 * 60 * 60
|
|
9
|
+
|
|
10
|
+
# Formats seconds into human-readable duration (e.g., "2h 30m")
|
|
11
|
+
# @param seconds [Integer] duration in seconds
|
|
12
|
+
# @return [String] formatted duration
|
|
13
|
+
def format_duration(seconds)
|
|
14
|
+
return '0m' if seconds.nil? || seconds <= 0
|
|
15
|
+
|
|
16
|
+
hours = seconds / SECONDS_PER_HOUR
|
|
17
|
+
minutes = (seconds % SECONDS_PER_HOUR) / 60
|
|
18
|
+
|
|
19
|
+
parts = []
|
|
20
|
+
parts << "#{hours}h" if hours.positive?
|
|
21
|
+
parts << "#{minutes}m" if minutes.positive?
|
|
22
|
+
parts.empty? ? '0m' : parts.join(' ')
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
module_function :format_duration
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tzinfo'
|
|
4
|
+
|
|
5
|
+
module Snitcher
|
|
6
|
+
# Validation utilities for user input sanitization.
|
|
7
|
+
# Prevents injection attacks when user input is used in queries or URLs.
|
|
8
|
+
module Validators
|
|
9
|
+
# RFC 5322 simplified email pattern - validates basic email format
|
|
10
|
+
# Prevents JQL injection by ensuring email doesn't contain query operators
|
|
11
|
+
EMAIL_LOCAL_PART = "[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+"
|
|
12
|
+
EMAIL_DOMAIN_PART = '[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?'
|
|
13
|
+
EMAIL_REGEX = /\A#{EMAIL_LOCAL_PART}@#{EMAIL_DOMAIN_PART}(?:\.#{EMAIL_DOMAIN_PART})*\z/
|
|
14
|
+
|
|
15
|
+
# Alphanumeric pattern for IDs used in URL paths
|
|
16
|
+
# Prevents path injection by ensuring IDs contain only safe characters
|
|
17
|
+
ALPHANUMERIC_ID_REGEX = /\A[a-zA-Z0-9_-]+\z/
|
|
18
|
+
|
|
19
|
+
class ValidationError < Error; end
|
|
20
|
+
|
|
21
|
+
# Validates email format to prevent injection in JQL queries
|
|
22
|
+
# @param email [String] email address to validate
|
|
23
|
+
# @raise [ValidationError] if email format is invalid
|
|
24
|
+
# @return [String] the validated email
|
|
25
|
+
def self.validate_email!(email)
|
|
26
|
+
raise ValidationError, 'Email is required' if email.nil? || email.empty?
|
|
27
|
+
raise ValidationError, "Invalid email format: #{email}" unless email.match?(EMAIL_REGEX)
|
|
28
|
+
|
|
29
|
+
email
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Validates ID format for safe use in URL paths
|
|
33
|
+
# @param id [String] ID to validate (team_id, user_id, etc.)
|
|
34
|
+
# @param name [String] descriptive name for error messages
|
|
35
|
+
# @raise [ValidationError] if ID format is invalid
|
|
36
|
+
# @return [String] the validated ID
|
|
37
|
+
def self.validate_id!(id, name: 'ID')
|
|
38
|
+
raise ValidationError, "#{name} is required" if id.nil? || id.to_s.empty?
|
|
39
|
+
raise ValidationError, "Invalid #{name} format: #{id}" unless id.to_s.match?(ALPHANUMERIC_ID_REGEX)
|
|
40
|
+
|
|
41
|
+
id.to_s
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Validates IANA timezone identifier using TZInfo.
|
|
45
|
+
# @param timezone [String] IANA timezone name (e.g., 'Europe/Warsaw')
|
|
46
|
+
# @raise [ValidationError] if timezone is invalid or not found
|
|
47
|
+
# @return [String] the validated timezone name
|
|
48
|
+
def self.validate_timezone!(timezone)
|
|
49
|
+
raise ValidationError, 'Timezone is required' if timezone.nil? || timezone.to_s.strip.empty?
|
|
50
|
+
|
|
51
|
+
stripped = timezone.to_s.strip
|
|
52
|
+
TZInfo::Timezone.get(stripped)
|
|
53
|
+
stripped
|
|
54
|
+
rescue TZInfo::InvalidTimezoneIdentifier
|
|
55
|
+
raise ValidationError,
|
|
56
|
+
"Unknown timezone: '#{stripped}'. Use IANA format, e.g. 'Europe/Warsaw', 'America/New_York'"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|