@littlebearapps/platform-admin-sdk 2.1.0 → 2.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.
Files changed (115) hide show
  1. package/README.md +2 -5
  2. package/dist/templates.d.ts +1 -1
  3. package/dist/templates.js +121 -3
  4. package/package.json +1 -1
  5. package/templates/full/dashboard/src/components/notifications/NotificationDropdown.tsx +130 -0
  6. package/templates/full/dashboard/src/components/notifications/NotificationItem.tsx +264 -0
  7. package/templates/full/dashboard/src/components/patterns/PatternInfoButton.tsx +60 -0
  8. package/templates/full/dashboard/src/components/reports/FeatureUsageReport.tsx +339 -0
  9. package/templates/full/dashboard/src/components/search/SearchResultGroup.tsx +46 -0
  10. package/templates/full/dashboard/src/components/search/SearchResultItem.tsx +212 -0
  11. package/templates/full/dashboard/src/pages/api/patterns/[id]/approve.ts +49 -0
  12. package/templates/full/dashboard/src/pages/api/patterns/[id]/reject.ts +50 -0
  13. package/templates/full/dashboard/src/pages/api/reports/digests/stats.ts +38 -0
  14. package/templates/full/dashboard/src/pages/api/reports/digests.ts +39 -0
  15. package/templates/full/dashboard/src/pages/api/search/reindex/[type].ts +56 -0
  16. package/templates/full/dashboard/src/pages/api/test-reports/[id].ts +102 -0
  17. package/templates/full/dashboard/src/pages/feedback.astro +365 -0
  18. package/templates/full/dashboard/src/pages/kiosk.astro +206 -0
  19. package/templates/full/dashboard/src/pages/map.astro +561 -0
  20. package/templates/full/dashboard/src/pages/revenue.astro +72 -0
  21. package/templates/full/dashboard/src/pages/tests.astro +431 -0
  22. package/templates/full/scripts/ops/audit-cost-anomaly.ts +430 -0
  23. package/templates/full/scripts/ops/verify-account-total.ts +256 -0
  24. package/templates/full/tests/integration/feedback-schema.test.ts +361 -0
  25. package/templates/full/tests/integration/r2-archive.test.ts +108 -0
  26. package/templates/shared/.github/workflows/dependabot-automerge.yml +41 -0
  27. package/templates/shared/.github/workflows/validate-controls.yml +27 -0
  28. package/templates/shared/dashboard/src/components/Breadcrumbs.astro +101 -0
  29. package/templates/shared/dashboard/src/components/EmptyState.astro +46 -0
  30. package/templates/shared/dashboard/src/components/ErrorBoundary.astro +79 -0
  31. package/templates/shared/dashboard/src/components/LoadingSkeleton.astro +105 -0
  32. package/templates/shared/dashboard/src/components/PageShell.astro +72 -0
  33. package/templates/shared/dashboard/src/components/SkipLinks.astro +22 -0
  34. package/templates/shared/dashboard/src/components/Toast.astro +170 -0
  35. package/templates/shared/dashboard/src/components/ToastContainer.astro +156 -0
  36. package/templates/shared/dashboard/src/components/costs/ProviderCostsGrid.tsx +401 -0
  37. package/templates/shared/dashboard/src/components/costs/index.ts +4 -0
  38. package/templates/shared/dashboard/src/components/overview/AlertBanner.tsx +94 -0
  39. package/templates/shared/dashboard/src/components/overview/index.ts +9 -0
  40. package/templates/shared/dashboard/src/components/resources/CostChart.tsx +170 -0
  41. package/templates/shared/dashboard/src/components/resources/ProviderCard.tsx +272 -0
  42. package/templates/shared/dashboard/src/components/resources/ProviderDetail.tsx +293 -0
  43. package/templates/shared/dashboard/src/components/settings/SettingsCard.astro +102 -0
  44. package/templates/shared/dashboard/src/components/usage/AllowanceGauge.astro +170 -0
  45. package/templates/shared/dashboard/src/components/usage/AnomalyAlerts.astro +633 -0
  46. package/templates/shared/dashboard/src/components/usage/BillingCycleCountdown.astro +192 -0
  47. package/templates/shared/dashboard/src/components/usage/BurnRateHero.astro +539 -0
  48. package/templates/shared/dashboard/src/components/usage/CircuitBreakerEventLog.astro +542 -0
  49. package/templates/shared/dashboard/src/components/usage/CircuitBreakerPanel.tsx +292 -0
  50. package/templates/shared/dashboard/src/components/usage/CircuitBreakerStatus.astro +669 -0
  51. package/templates/shared/dashboard/src/components/usage/CompactThresholdBanner.astro +531 -0
  52. package/templates/shared/dashboard/src/components/usage/ComparisonModeSelector.astro +651 -0
  53. package/templates/shared/dashboard/src/components/usage/CostBreakdownChart.astro +381 -0
  54. package/templates/shared/dashboard/src/components/usage/CostBreakdownTable.astro +210 -0
  55. package/templates/shared/dashboard/src/components/usage/CostDataTable.astro +0 -0
  56. package/templates/shared/dashboard/src/components/usage/CostDonutChart.astro +311 -0
  57. package/templates/shared/dashboard/src/components/usage/DailyCostChart.astro +632 -0
  58. package/templates/shared/dashboard/src/components/usage/ExportButton.astro +114 -0
  59. package/templates/shared/dashboard/src/components/usage/FeatureBudgetsTable.astro +872 -0
  60. package/templates/shared/dashboard/src/components/usage/FilterBar.astro +190 -0
  61. package/templates/shared/dashboard/src/components/usage/FilterToggles.astro +175 -0
  62. package/templates/shared/dashboard/src/components/usage/GitHubUsageCard.astro +537 -0
  63. package/templates/shared/dashboard/src/components/usage/OverageCostCard.astro +212 -0
  64. package/templates/shared/dashboard/src/components/usage/PlanUtilizationCard.astro +193 -0
  65. package/templates/shared/dashboard/src/components/usage/ProjectCard.astro +640 -0
  66. package/templates/shared/dashboard/src/components/usage/ProjectCardsGrid.astro +272 -0
  67. package/templates/shared/dashboard/src/components/usage/ResourceSearch.astro +279 -0
  68. package/templates/shared/dashboard/src/components/usage/ServiceUtilizationList.astro +604 -0
  69. package/templates/shared/dashboard/src/components/usage/SparklineCard.astro +399 -0
  70. package/templates/shared/dashboard/src/components/usage/StatsHero.astro +600 -0
  71. package/templates/shared/dashboard/src/components/usage/TableFilters.astro +1033 -0
  72. package/templates/shared/dashboard/src/components/usage/ThresholdAlert.astro +271 -0
  73. package/templates/shared/dashboard/src/components/usage/ThresholdSettings.astro +618 -0
  74. package/templates/shared/dashboard/src/components/usage/TopSpenderCard.astro +170 -0
  75. package/templates/shared/dashboard/src/components/usage/UnifiedResourceTable.astro +1737 -0
  76. package/templates/shared/dashboard/src/components/usage/UsageCard.astro +135 -0
  77. package/templates/shared/dashboard/src/components/usage/UsageHealthBanner.astro +387 -0
  78. package/templates/shared/dashboard/src/components/usage/UtilizationBar.astro +159 -0
  79. package/templates/shared/dashboard/src/components/usage/WorkersBreakdownTable.astro +659 -0
  80. package/templates/shared/dashboard/src/components/usage/daily/CostChart.astro +461 -0
  81. package/templates/shared/dashboard/src/components/usage/daily/CostTable.astro +946 -0
  82. package/templates/shared/dashboard/src/components/usage/daily/DailyOverview.astro +1079 -0
  83. package/templates/shared/dashboard/src/components/usage/design-tokens.ts +187 -0
  84. package/templates/shared/dashboard/src/components/usage/filters/InlineDateRange.astro +285 -0
  85. package/templates/shared/dashboard/src/components/usage/filters/PeriodButtons.astro +157 -0
  86. package/templates/shared/dashboard/src/components/usage/filters/ProjectSelect.astro +284 -0
  87. package/templates/shared/dashboard/src/components/usage/scripts/ai-tab-controller.ts +419 -0
  88. package/templates/shared/dashboard/src/components/usage/scripts/constants.ts +60 -0
  89. package/templates/shared/dashboard/src/components/usage/scripts/formatters.ts +62 -0
  90. package/templates/shared/dashboard/src/components/usage/scripts/overview-controller.ts +1633 -0
  91. package/templates/shared/dashboard/src/components/usage/scripts/resource-table-builder.ts +294 -0
  92. package/templates/shared/dashboard/src/components/usage/scripts/tabs-filters-controller.ts +464 -0
  93. package/templates/shared/dashboard/src/components/usage/state/index.ts +55 -0
  94. package/templates/shared/dashboard/src/components/usage/state/usageActions.ts +439 -0
  95. package/templates/shared/dashboard/src/components/usage/state/usageStore.ts +376 -0
  96. package/templates/shared/dashboard/src/components/usage/types.ts +283 -0
  97. package/templates/shared/dashboard/src/components/usage/usage-colors.ts +292 -0
  98. package/templates/shared/dashboard/src/pages/api/usage/ai-models.ts +235 -0
  99. package/templates/shared/dashboard/src/pages/api/usage/billing-context.ts +296 -0
  100. package/templates/shared/scripts/test-telemetry-flow.ts +464 -0
  101. package/templates/shared/tests/e2e/usage-export.test.ts +784 -0
  102. package/templates/shared/tests/e2e/usage-mobile.test.ts +531 -0
  103. package/templates/standard/dashboard/src/components/errors/PriorityBadge.astro +27 -0
  104. package/templates/standard/dashboard/src/components/infrastructure/HealthchecksStatus.tsx +293 -0
  105. package/templates/standard/dashboard/src/components/infrastructure/InfrastructureTabs.tsx +268 -0
  106. package/templates/standard/dashboard/src/pages/analytics.astro +64 -0
  107. package/templates/standard/dashboard/src/pages/api/infrastructure/alerts.ts +85 -0
  108. package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks/[id]/flips.ts +110 -0
  109. package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks.ts +101 -0
  110. package/templates/standard/dashboard/src/pages/api/infrastructure/uptime/[id]/response-times.ts +121 -0
  111. package/templates/standard/dashboard/src/pages/api/infrastructure/uptime.ts +89 -0
  112. package/templates/standard/dashboard/src/pages/api/test/service-auth.ts +178 -0
  113. package/templates/standard/tests/integration/connectors.test.ts +241 -0
  114. package/templates/standard/tests/integration/github-monitor.test.ts +143 -0
  115. package/templates/standard/tests/integration/ingestion.test.ts +211 -0
@@ -0,0 +1,651 @@
1
+ ---
2
+ /**
3
+ * ComparisonModeSelector Component (task-17.10, enhanced in task-17.18)
4
+ *
5
+ * Dropdown to select comparison mode for historical views.
6
+ * Options: Previous Period, Same Period Last Month, Custom Range.
7
+ *
8
+ * Task-17.18 Enhancements:
9
+ * - Date range presets (Last 7d, 30d, 90d, This month, Last month)
10
+ * - Inline validation feedback (no alerts)
11
+ * - Improved mobile styling
12
+ */
13
+
14
+ import type { CompareMode } from './types';
15
+
16
+ export interface Props {
17
+ currentMode: CompareMode;
18
+ currentPeriod: string;
19
+ customStartDate?: string;
20
+ customEndDate?: string;
21
+ }
22
+
23
+ const {
24
+ currentMode = 'none',
25
+ currentPeriod = '30d',
26
+ customStartDate = '',
27
+ customEndDate = '',
28
+ } = Astro.props;
29
+
30
+ // Mode labels
31
+ const modeLabels: Record<CompareMode, string> = {
32
+ none: 'No Comparison',
33
+ lastMonth: 'vs Same Period Last Month',
34
+ custom: 'Custom Date Range',
35
+ };
36
+
37
+ // Mode descriptions
38
+ const modeDescriptions: Record<CompareMode, string> = {
39
+ none: 'Show current period only',
40
+ lastMonth: 'Compare with the same period from last month',
41
+ custom: 'Select a custom date range to compare',
42
+ };
43
+
44
+ // Date presets for quick selection
45
+ const datePresets = [
46
+ { id: 'last7d', label: 'Last 7d', days: 7 },
47
+ { id: 'last30d', label: 'Last 30d', days: 30 },
48
+ { id: 'last90d', label: 'Last 90d', days: 90 },
49
+ { id: 'thisMonth', label: 'This month', days: 0 }, // Special handling
50
+ { id: 'lastMonth', label: 'Last month', days: -1 }, // Special handling
51
+ ];
52
+ ---
53
+
54
+ <div class="comparison-mode-selector" data-component="comparison-mode-selector">
55
+ <div class="selector-group">
56
+ <label for="compare-mode" class="selector-label">Compare</label>
57
+ <div class="select-wrapper">
58
+ <select
59
+ id="compare-mode"
60
+ name="compare"
61
+ class="selector-select"
62
+ aria-describedby="compare-mode-desc"
63
+ >
64
+ {
65
+ Object.entries(modeLabels).map(([mode, label]) => (
66
+ <option value={mode} selected={mode === currentMode}>
67
+ {label}
68
+ </option>
69
+ ))
70
+ }
71
+ </select>
72
+ <span class="select-icon" aria-hidden="true">
73
+ <svg
74
+ width="12"
75
+ height="12"
76
+ viewBox="0 0 12 12"
77
+ fill="none"
78
+ xmlns="http://www.w3.org/2000/svg"
79
+ >
80
+ <path
81
+ d="M3 4.5L6 7.5L9 4.5"
82
+ stroke="currentColor"
83
+ stroke-width="1.5"
84
+ stroke-linecap="round"
85
+ stroke-linejoin="round"></path>
86
+ </svg>
87
+ </span>
88
+ </div>
89
+ </div>
90
+
91
+ <!-- Custom date range inputs (hidden by default) -->
92
+ <div
93
+ id="custom-date-range"
94
+ class="custom-date-range"
95
+ style={currentMode !== 'custom' ? 'display: none;' : ''}
96
+ >
97
+ <!-- Date Range Presets (Task-17.18) -->
98
+ <div class="date-presets">
99
+ <span class="presets-label">Quick:</span>
100
+ <div class="presets-buttons">
101
+ {
102
+ datePresets.map((preset) => (
103
+ <button
104
+ type="button"
105
+ class="preset-button"
106
+ data-preset={preset.id}
107
+ data-days={preset.days}
108
+ >
109
+ {preset.label}
110
+ </button>
111
+ ))
112
+ }
113
+ </div>
114
+ </div>
115
+
116
+ <div class="date-inputs-row">
117
+ <div class="date-input-group">
118
+ <label for="custom-start-date" class="date-label">From</label>
119
+ <input
120
+ type="date"
121
+ id="custom-start-date"
122
+ name="startDate"
123
+ class="date-input"
124
+ value={customStartDate}
125
+ max={new Date().toISOString().split('T')[0]}
126
+ />
127
+ </div>
128
+ <div class="date-input-group">
129
+ <label for="custom-end-date" class="date-label">To</label>
130
+ <input
131
+ type="date"
132
+ id="custom-end-date"
133
+ name="endDate"
134
+ class="date-input"
135
+ value={customEndDate}
136
+ max={new Date().toISOString().split('T')[0]}
137
+ />
138
+ </div>
139
+ <button type="button" id="apply-custom-range" class="apply-button"> Apply </button>
140
+ </div>
141
+
142
+ <!-- Validation feedback (Task-17.18) -->
143
+ <div id="date-validation-feedback" class="validation-feedback" role="alert" aria-live="polite">
144
+ </div>
145
+ </div>
146
+
147
+ <p id="compare-mode-desc" class="selector-description">
148
+ {modeDescriptions[currentMode]}
149
+ </p>
150
+ </div>
151
+
152
+ <style>
153
+ .comparison-mode-selector {
154
+ display: flex;
155
+ flex-direction: column;
156
+ gap: 0.5rem;
157
+ }
158
+
159
+ .selector-group {
160
+ display: flex;
161
+ align-items: center;
162
+ gap: 0.5rem;
163
+ }
164
+
165
+ .selector-label {
166
+ font-size: 0.75rem;
167
+ font-weight: 500;
168
+ color: #6b7280;
169
+ text-transform: uppercase;
170
+ letter-spacing: 0.05em;
171
+ }
172
+
173
+ :global(.dark) .selector-label {
174
+ color: #9ca3af;
175
+ }
176
+
177
+ .select-wrapper {
178
+ position: relative;
179
+ display: inline-flex;
180
+ }
181
+
182
+ .selector-select {
183
+ appearance: none;
184
+ background-color: white;
185
+ border: 1px solid #d1d5db;
186
+ border-radius: 0.375rem;
187
+ padding: 0.5rem 2rem 0.5rem 0.75rem;
188
+ font-size: 0.875rem;
189
+ color: #374151;
190
+ cursor: pointer;
191
+ transition: all 0.15s ease;
192
+ min-width: 200px;
193
+ }
194
+
195
+ :global(.dark) .selector-select {
196
+ background-color: #1f2937;
197
+ border-color: #374151;
198
+ color: #f9fafb;
199
+ }
200
+
201
+ .selector-select:hover {
202
+ border-color: #9ca3af;
203
+ }
204
+
205
+ .selector-select:focus {
206
+ outline: none;
207
+ border-color: #3b82f6;
208
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
209
+ }
210
+
211
+ .select-icon {
212
+ position: absolute;
213
+ right: 0.75rem;
214
+ top: 50%;
215
+ transform: translateY(-50%);
216
+ pointer-events: none;
217
+ color: #6b7280;
218
+ }
219
+
220
+ .custom-date-range {
221
+ display: flex;
222
+ flex-direction: column;
223
+ gap: 0.75rem;
224
+ padding: 0.75rem;
225
+ background-color: #f9fafb;
226
+ border-radius: 0.375rem;
227
+ margin-top: 0.25rem;
228
+ }
229
+
230
+ :global(.dark) .custom-date-range {
231
+ background-color: #111827;
232
+ }
233
+
234
+ /* Date Presets (Task-17.18) */
235
+ .date-presets {
236
+ display: flex;
237
+ align-items: center;
238
+ gap: 0.5rem;
239
+ flex-wrap: wrap;
240
+ }
241
+
242
+ .presets-label {
243
+ font-size: 0.625rem;
244
+ font-weight: 500;
245
+ color: #6b7280;
246
+ text-transform: uppercase;
247
+ letter-spacing: 0.05em;
248
+ }
249
+
250
+ :global(.dark) .presets-label {
251
+ color: #9ca3af;
252
+ }
253
+
254
+ .presets-buttons {
255
+ display: flex;
256
+ gap: 0.25rem;
257
+ flex-wrap: wrap;
258
+ }
259
+
260
+ .preset-button {
261
+ padding: 0.25rem 0.5rem;
262
+ background-color: white;
263
+ border: 1px solid #e5e7eb;
264
+ border-radius: 0.25rem;
265
+ font-size: 0.6875rem;
266
+ color: #6b7280;
267
+ cursor: pointer;
268
+ transition: all 0.15s ease;
269
+ white-space: nowrap;
270
+ }
271
+
272
+ .preset-button:hover {
273
+ background-color: #f3f4f6;
274
+ border-color: #d1d5db;
275
+ color: #374151;
276
+ }
277
+
278
+ .preset-button:active,
279
+ .preset-button.active {
280
+ background-color: #3b82f6;
281
+ border-color: #3b82f6;
282
+ color: white;
283
+ }
284
+
285
+ :global(.dark) .preset-button {
286
+ background-color: #1f2937;
287
+ border-color: #374151;
288
+ color: #9ca3af;
289
+ }
290
+
291
+ :global(.dark) .preset-button:hover {
292
+ background-color: #374151;
293
+ color: #f9fafb;
294
+ }
295
+
296
+ :global(.dark) .preset-button:active,
297
+ :global(.dark) .preset-button.active {
298
+ background-color: #3b82f6;
299
+ border-color: #3b82f6;
300
+ color: white;
301
+ }
302
+
303
+ /* Date Inputs Row */
304
+ .date-inputs-row {
305
+ display: flex;
306
+ align-items: flex-end;
307
+ gap: 0.75rem;
308
+ }
309
+
310
+ .date-input-group {
311
+ display: flex;
312
+ flex-direction: column;
313
+ gap: 0.25rem;
314
+ }
315
+
316
+ .date-label {
317
+ font-size: 0.625rem;
318
+ font-weight: 500;
319
+ color: #6b7280;
320
+ text-transform: uppercase;
321
+ letter-spacing: 0.05em;
322
+ }
323
+
324
+ :global(.dark) .date-label {
325
+ color: #9ca3af;
326
+ }
327
+
328
+ .date-input {
329
+ appearance: none;
330
+ background-color: white;
331
+ border: 1px solid #d1d5db;
332
+ border-radius: 0.25rem;
333
+ padding: 0.375rem 0.5rem;
334
+ font-size: 0.75rem;
335
+ color: #374151;
336
+ min-width: 130px;
337
+ }
338
+
339
+ .date-input.invalid {
340
+ border-color: #ef4444;
341
+ }
342
+
343
+ :global(.dark) .date-input {
344
+ background-color: #1f2937;
345
+ border-color: #374151;
346
+ color: #f9fafb;
347
+ }
348
+
349
+ .date-input:focus {
350
+ outline: none;
351
+ border-color: #3b82f6;
352
+ }
353
+
354
+ .apply-button {
355
+ padding: 0.375rem 0.75rem;
356
+ background-color: #3b82f6;
357
+ color: white;
358
+ border: none;
359
+ border-radius: 0.25rem;
360
+ font-size: 0.75rem;
361
+ font-weight: 500;
362
+ cursor: pointer;
363
+ transition: background-color 0.15s ease;
364
+ white-space: nowrap;
365
+ }
366
+
367
+ .apply-button:hover {
368
+ background-color: #2563eb;
369
+ }
370
+
371
+ .apply-button:active {
372
+ background-color: #1d4ed8;
373
+ }
374
+
375
+ .apply-button:disabled {
376
+ background-color: #9ca3af;
377
+ cursor: not-allowed;
378
+ }
379
+
380
+ /* Validation Feedback (Task-17.18) */
381
+ .validation-feedback {
382
+ font-size: 0.6875rem;
383
+ padding: 0.375rem 0.5rem;
384
+ border-radius: 0.25rem;
385
+ display: none;
386
+ }
387
+
388
+ .validation-feedback.error {
389
+ display: block;
390
+ background-color: #fef2f2;
391
+ color: #b91c1c;
392
+ border: 1px solid #fecaca;
393
+ }
394
+
395
+ .validation-feedback.info {
396
+ display: block;
397
+ background-color: #eff6ff;
398
+ color: #1d4ed8;
399
+ border: 1px solid #bfdbfe;
400
+ }
401
+
402
+ :global(.dark) .validation-feedback.error {
403
+ background-color: #450a0a;
404
+ color: #fca5a5;
405
+ border-color: #7f1d1d;
406
+ }
407
+
408
+ :global(.dark) .validation-feedback.info {
409
+ background-color: #172554;
410
+ color: #93c5fd;
411
+ border-color: #1e40af;
412
+ }
413
+
414
+ .selector-description {
415
+ font-size: 0.625rem;
416
+ color: #9ca3af;
417
+ margin: 0;
418
+ }
419
+
420
+ /* Mobile responsive (Task-17.18 enhanced) */
421
+ @media (max-width: 640px) {
422
+ .comparison-mode-selector {
423
+ width: 100%;
424
+ }
425
+
426
+ .selector-select {
427
+ width: 100%;
428
+ min-width: unset;
429
+ }
430
+
431
+ .date-presets {
432
+ flex-direction: column;
433
+ align-items: flex-start;
434
+ }
435
+
436
+ .presets-buttons {
437
+ width: 100%;
438
+ }
439
+
440
+ .preset-button {
441
+ flex: 1;
442
+ text-align: center;
443
+ padding: 0.5rem 0.25rem;
444
+ }
445
+
446
+ .date-inputs-row {
447
+ flex-wrap: wrap;
448
+ }
449
+
450
+ .date-input-group {
451
+ flex: 1;
452
+ min-width: 45%;
453
+ }
454
+
455
+ .date-input {
456
+ width: 100%;
457
+ min-width: unset;
458
+ }
459
+
460
+ .apply-button {
461
+ width: 100%;
462
+ margin-top: 0.5rem;
463
+ padding: 0.5rem;
464
+ }
465
+ }
466
+ </style>
467
+
468
+ <script>
469
+ document.addEventListener('DOMContentLoaded', () => {
470
+ const selector = document.querySelector('[data-component="comparison-mode-selector"]');
471
+ if (!selector) return;
472
+
473
+ const modeSelect = selector.querySelector('#compare-mode') as HTMLSelectElement;
474
+ const customRange = selector.querySelector('#custom-date-range') as HTMLElement;
475
+ const startDateInput = selector.querySelector('#custom-start-date') as HTMLInputElement;
476
+ const endDateInput = selector.querySelector('#custom-end-date') as HTMLInputElement;
477
+ const applyButton = selector.querySelector('#apply-custom-range') as HTMLButtonElement;
478
+ const description = selector.querySelector('.selector-description') as HTMLElement;
479
+ const validationFeedback = selector.querySelector('#date-validation-feedback') as HTMLElement;
480
+ const presetButtons = selector.querySelectorAll('.preset-button');
481
+
482
+ const descriptions: Record<string, string> = {
483
+ none: 'Show current period only',
484
+ lastMonth: 'Compare with the same period from last month',
485
+ custom: 'Select a custom date range to compare',
486
+ };
487
+
488
+ // Helper: Format date as YYYY-MM-DD
489
+ function formatDate(date: Date): string {
490
+ return date.toISOString().split('T')[0];
491
+ }
492
+
493
+ // Helper: Show validation feedback
494
+ function showFeedback(message: string, type: 'error' | 'info') {
495
+ validationFeedback.textContent = message;
496
+ validationFeedback.className = 'validation-feedback ' + type;
497
+ }
498
+
499
+ // Helper: Hide validation feedback
500
+ function hideFeedback() {
501
+ validationFeedback.textContent = '';
502
+ validationFeedback.className = 'validation-feedback';
503
+ }
504
+
505
+ // Helper: Validate date range
506
+ function validateDateRange(): { valid: boolean; error?: string; days?: number } {
507
+ const startDate = startDateInput.value;
508
+ const endDate = endDateInput.value;
509
+
510
+ if (!startDate || !endDate) {
511
+ return { valid: false, error: 'Please select both start and end dates' };
512
+ }
513
+
514
+ const start = new Date(startDate);
515
+ const end = new Date(endDate);
516
+
517
+ if (start > end) {
518
+ startDateInput.classList.add('invalid');
519
+ return { valid: false, error: 'Start date must be before end date' };
520
+ }
521
+
522
+ const diffDays = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
523
+
524
+ if (diffDays > 90) {
525
+ return {
526
+ valid: false,
527
+ error: 'Maximum date range is 90 days. Selected: ' + diffDays + ' days',
528
+ };
529
+ }
530
+
531
+ startDateInput.classList.remove('invalid');
532
+ endDateInput.classList.remove('invalid');
533
+ return { valid: true, days: diffDays };
534
+ }
535
+
536
+ // Handle mode change
537
+ modeSelect.addEventListener('change', () => {
538
+ const mode = modeSelect.value;
539
+
540
+ // Toggle custom date range visibility
541
+ if (mode === 'custom') {
542
+ customRange.style.display = 'flex';
543
+ hideFeedback();
544
+ } else {
545
+ customRange.style.display = 'none';
546
+ // Update URL and trigger fetch for non-custom modes
547
+ updateUrlAndFetch(mode);
548
+ }
549
+
550
+ // Update description
551
+ description.textContent = descriptions[mode] || '';
552
+ });
553
+
554
+ // Handle preset button clicks (Task-17.18)
555
+ presetButtons.forEach((button) => {
556
+ button.addEventListener('click', () => {
557
+ const presetId = button.getAttribute('data-preset');
558
+ const days = parseInt(button.getAttribute('data-days') || '0', 10);
559
+ const today = new Date();
560
+ let startDate: Date;
561
+ let endDate: Date;
562
+
563
+ // Clear active state from all buttons
564
+ presetButtons.forEach((btn) => btn.classList.remove('active'));
565
+ button.classList.add('active');
566
+
567
+ if (presetId === 'thisMonth') {
568
+ // This month: 1st of current month to today
569
+ startDate = new Date(today.getFullYear(), today.getMonth(), 1);
570
+ endDate = today;
571
+ } else if (presetId === 'lastMonth') {
572
+ // Last month: 1st to last day of previous month
573
+ startDate = new Date(today.getFullYear(), today.getMonth() - 1, 1);
574
+ endDate = new Date(today.getFullYear(), today.getMonth(), 0);
575
+ } else {
576
+ // Last N days
577
+ endDate = today;
578
+ startDate = new Date(today);
579
+ startDate.setDate(startDate.getDate() - days);
580
+ }
581
+
582
+ startDateInput.value = formatDate(startDate);
583
+ endDateInput.value = formatDate(endDate);
584
+
585
+ // Validate and show info
586
+ const validation = validateDateRange();
587
+ if (validation.valid) {
588
+ showFeedback('Selected ' + validation.days + ' days', 'info');
589
+ }
590
+ });
591
+ });
592
+
593
+ // Real-time validation on date change (Task-17.18)
594
+ function handleDateChange() {
595
+ const validation = validateDateRange();
596
+ if (startDateInput.value && endDateInput.value) {
597
+ if (!validation.valid && validation.error) {
598
+ showFeedback(validation.error, 'error');
599
+ applyButton.disabled = true;
600
+ } else {
601
+ showFeedback('Selected ' + validation.days + ' days', 'info');
602
+ applyButton.disabled = false;
603
+ }
604
+ } else {
605
+ hideFeedback();
606
+ applyButton.disabled = false;
607
+ }
608
+ }
609
+
610
+ startDateInput.addEventListener('change', handleDateChange);
611
+ endDateInput.addEventListener('change', handleDateChange);
612
+
613
+ // Handle apply button click
614
+ applyButton.addEventListener('click', () => {
615
+ const validation = validateDateRange();
616
+
617
+ if (!validation.valid) {
618
+ if (validation.error) {
619
+ showFeedback(validation.error, 'error');
620
+ }
621
+ return;
622
+ }
623
+
624
+ hideFeedback();
625
+ updateUrlAndFetch('custom', startDateInput.value, endDateInput.value);
626
+ });
627
+
628
+ function updateUrlAndFetch(mode: string, startDate?: string, endDate?: string) {
629
+ const url = new URL(window.location.href);
630
+ url.searchParams.set('compare', mode);
631
+
632
+ if (mode === 'custom' && startDate && endDate) {
633
+ url.searchParams.set('startDate', startDate);
634
+ url.searchParams.set('endDate', endDate);
635
+ } else {
636
+ url.searchParams.delete('startDate');
637
+ url.searchParams.delete('endDate');
638
+ }
639
+
640
+ // Update URL without reload
641
+ window.history.pushState({}, '', url);
642
+
643
+ // Dispatch custom event for parent to handle
644
+ const event = new CustomEvent('compare-mode-change', {
645
+ bubbles: true,
646
+ detail: { mode, startDate, endDate },
647
+ });
648
+ selector.dispatchEvent(event);
649
+ }
650
+ });
651
+ </script>