@checkstack/ui 0.4.1 → 0.5.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,59 @@
1
1
  # @checkstack/ui
2
2
 
3
+ ## 0.5.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 090143b: ### Health Check Aggregation & UI Fixes
8
+
9
+ **Backend (`healthcheck-backend`):**
10
+
11
+ - Fixed tail-end bucket truncation where the last aggregated bucket was cut off at the interval boundary instead of extending to the query end date
12
+ - Added `rangeEnd` parameter to `reaggregateBuckets()` to properly extend the last bucket
13
+ - Fixed cross-tier merge logic (`mergeTieredBuckets`) to prevent hourly aggregates from blocking fresh raw data
14
+
15
+ **Schema (`healthcheck-common`):**
16
+
17
+ - Added `bucketEnd` field to `AggregatedBucketBaseSchema` so frontends know the actual end time of each bucket
18
+
19
+ **Frontend (`healthcheck-frontend`):**
20
+
21
+ - Updated all components to use `bucket.bucketEnd` instead of calculating from `bucketIntervalSeconds`
22
+ - Fixed aggregation mode detection: changed `>` to `>=` so 7-day queries use aggregated data when `rawRetentionDays` is 7
23
+ - Added ref-based memoization in `useHealthCheckData` to prevent layout shift during signal-triggered refetches
24
+ - Exposed `isFetching` state to show loading spinner during background refetches
25
+ - Added debounced custom date range with Apply button to prevent fetching on every field change
26
+ - Added validation preventing start date >= end date in custom ranges
27
+ - Added sparkline downsampling: when there are 60+ data points, they are aggregated into buckets with informative tooltips
28
+
29
+ **UI (`ui`):**
30
+
31
+ - Fixed `DateRangeFilter` presets to use true sliding windows (removed `startOfDay` from 7-day and 30-day ranges)
32
+ - Added `disabled` prop to `DateRangeFilter` and `DateTimePicker` components
33
+ - Added `onCustomChange` prop to `DateRangeFilter` for debounced custom date handling
34
+ - Improved layout: custom date pickers now inline with preset buttons on desktop
35
+ - Added responsive mobile layout: date pickers stack vertically with down arrow
36
+ - Added validation error display for invalid date ranges
37
+
38
+ ## 0.5.0
39
+
40
+ ### Minor Changes
41
+
42
+ - 223081d: Add icon support to PageLayout and improve mobile responsiveness
43
+
44
+ **PageLayout Icons:**
45
+
46
+ - Added required `icon` prop to `PageLayout` and `PageHeader` components that accepts a Lucide icon component reference
47
+ - Icons are rendered with consistent `h-6 w-6 text-primary` styling
48
+ - Updated all page components to include appropriate icons in their headers
49
+
50
+ **Mobile Layout Improvements:**
51
+
52
+ - Standardized responsive padding in main app shell (`p-3` on mobile, `p-6` on desktop)
53
+ - Added `CardHeaderRow` component for mobile-safe card headers with proper wrapping
54
+ - Improved `DateRangeFilter` responsive behavior with vertical stacking on mobile
55
+ - Migrated pages to use `PageLayout` for consistent responsive behavior
56
+
3
57
  ## 0.4.1
4
58
 
5
59
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/ui",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "dependencies": {
@@ -8,7 +8,7 @@ export const Card = ({
8
8
  <div
9
9
  className={cn(
10
10
  "rounded-lg border border-border bg-card text-card-foreground shadow-sm",
11
- className
11
+ className,
12
12
  )}
13
13
  {...props}
14
14
  />
@@ -21,6 +21,23 @@ export const CardHeader = ({
21
21
  <div className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
22
22
  );
23
23
 
24
+ /**
25
+ * A row layout for card headers with title and actions.
26
+ * Provides mobile-friendly defaults with flex-wrap and gap.
27
+ */
28
+ export const CardHeaderRow = ({
29
+ className,
30
+ ...props
31
+ }: React.HTMLAttributes<HTMLDivElement>) => (
32
+ <div
33
+ className={cn(
34
+ "flex flex-wrap items-center justify-between gap-3",
35
+ className,
36
+ )}
37
+ {...props}
38
+ />
39
+ );
40
+
24
41
  export const CardTitle = ({
25
42
  className,
26
43
  ...props
@@ -28,7 +45,7 @@ export const CardTitle = ({
28
45
  <h3
29
46
  className={cn(
30
47
  "text-2xl font-semibold leading-none tracking-tight",
31
- className
48
+ className,
32
49
  )}
33
50
  {...props}
34
51
  />
@@ -1,5 +1,5 @@
1
1
  import React, { useState, useMemo } from "react";
2
- import { subDays, subHours, startOfDay } from "date-fns";
2
+ import { subDays, subHours } from "date-fns";
3
3
  import { DateTimePicker } from "./DateTimePicker";
4
4
  import { Button } from "./Button";
5
5
  import { Calendar } from "lucide-react";
@@ -18,15 +18,24 @@ export enum DateRangePreset {
18
18
 
19
19
  export interface DateRangeFilterProps {
20
20
  value: DateRange;
21
+ /** Called when a preset is clicked (immediate) */
21
22
  onChange: (range: DateRange) => void;
23
+ /** Optional: Called when custom date picker values change (for debounced Apply pattern) */
24
+ onCustomChange?: (range: DateRange) => void;
25
+ /** Disable all interactions (buttons and date pickers) */
26
+ disabled?: boolean;
22
27
  className?: string;
23
28
  }
24
29
 
25
- const PRESETS: Array<{ id: DateRangePreset; label: string }> = [
26
- { id: DateRangePreset.Last24Hours, label: "Last 24h" },
27
- { id: DateRangePreset.Last7Days, label: "Last 7 days" },
28
- { id: DateRangePreset.Last30Days, label: "Last 30 days" },
29
- { id: DateRangePreset.Custom, label: "Custom" },
30
+ const PRESETS: Array<{
31
+ id: DateRangePreset;
32
+ label: string;
33
+ shortLabel: string;
34
+ }> = [
35
+ { id: DateRangePreset.Last24Hours, label: "Last 24h", shortLabel: "24h" },
36
+ { id: DateRangePreset.Last7Days, label: "Last 7 days", shortLabel: "7d" },
37
+ { id: DateRangePreset.Last30Days, label: "Last 30 days", shortLabel: "30d" },
38
+ { id: DateRangePreset.Custom, label: "Custom", shortLabel: "Custom" },
30
39
  ];
31
40
 
32
41
  export function getPresetRange(preset: DateRangePreset): DateRange {
@@ -36,13 +45,13 @@ export function getPresetRange(preset: DateRangePreset): DateRange {
36
45
  return { startDate: subHours(now, 24), endDate: now };
37
46
  }
38
47
  case DateRangePreset.Last7Days: {
39
- return { startDate: startOfDay(subDays(now, 7)), endDate: now };
48
+ return { startDate: subDays(now, 7), endDate: now };
40
49
  }
41
50
  case DateRangePreset.Last30Days: {
42
- return { startDate: startOfDay(subDays(now, 30)), endDate: now };
51
+ return { startDate: subDays(now, 30), endDate: now };
43
52
  }
44
53
  case DateRangePreset.Custom: {
45
- return { startDate: startOfDay(subDays(now, 7)), endDate: now };
54
+ return { startDate: subDays(now, 7), endDate: now };
46
55
  }
47
56
  }
48
57
  }
@@ -65,6 +74,8 @@ function detectPreset(range: DateRange): DateRangePreset {
65
74
  export const DateRangeFilter: React.FC<DateRangeFilterProps> = ({
66
75
  value,
67
76
  onChange,
77
+ onCustomChange,
78
+ disabled = false,
68
79
  className,
69
80
  }) => {
70
81
  const activePreset = useMemo(() => detectPreset(value), [value]);
@@ -73,6 +84,7 @@ export const DateRangeFilter: React.FC<DateRangeFilterProps> = ({
73
84
  );
74
85
 
75
86
  const handlePresetClick = (preset: DateRangePreset) => {
87
+ if (disabled) return;
76
88
  if (preset === DateRangePreset.Custom) {
77
89
  setShowCustom(true);
78
90
  } else {
@@ -81,14 +93,20 @@ export const DateRangeFilter: React.FC<DateRangeFilterProps> = ({
81
93
  }
82
94
  };
83
95
 
96
+ // Use onCustomChange if provided, otherwise fall back to onChange
97
+ const handleCustomDateChange = onCustomChange ?? onChange;
98
+
99
+ // Validate date range
100
+ const isInvalidRange = showCustom && value.startDate >= value.endDate;
101
+
84
102
  return (
85
- <div className={`space-y-3 ${className ?? ""}`}>
103
+ <div className={className}>
86
104
  <div className="flex items-center gap-2 flex-wrap">
87
- <Calendar className="h-4 w-4 text-muted-foreground" />
88
- <span className="text-sm font-medium text-muted-foreground">
105
+ <Calendar className="h-4 w-4 text-muted-foreground shrink-0" />
106
+ <span className="text-sm font-medium text-muted-foreground shrink-0">
89
107
  Time range:
90
108
  </span>
91
- <div className="flex gap-1">
109
+ <div className="flex gap-1 flex-wrap">
92
110
  {PRESETS.map((preset) => (
93
111
  <Button
94
112
  key={preset.id}
@@ -101,37 +119,73 @@ export const DateRangeFilter: React.FC<DateRangeFilterProps> = ({
101
119
  }
102
120
  size="sm"
103
121
  onClick={() => handlePresetClick(preset.id)}
122
+ disabled={disabled}
104
123
  >
105
- {preset.label}
124
+ <span className="sm:hidden">{preset.shortLabel}</span>
125
+ <span className="hidden sm:inline">{preset.label}</span>
106
126
  </Button>
107
127
  ))}
108
128
  </div>
129
+ {showCustom && (
130
+ <>
131
+ <div className="w-px h-5 bg-border hidden sm:block" />
132
+ {/* Desktop: inline with right arrow */}
133
+ <div className="hidden sm:flex items-center gap-2">
134
+ <DateTimePicker
135
+ value={value.startDate}
136
+ onChange={(startDate) => {
137
+ if (startDate && !disabled) {
138
+ handleCustomDateChange({ ...value, startDate });
139
+ }
140
+ }}
141
+ maxDate={value.endDate}
142
+ disabled={disabled}
143
+ />
144
+ <span className="text-sm text-muted-foreground">→</span>
145
+ <DateTimePicker
146
+ value={value.endDate}
147
+ onChange={(endDate) => {
148
+ if (endDate && !disabled) {
149
+ handleCustomDateChange({ ...value, endDate });
150
+ }
151
+ }}
152
+ minDate={value.startDate}
153
+ maxDate={new Date()}
154
+ disabled={disabled}
155
+ />
156
+ </div>
157
+ {/* Mobile: stacked vertically with down arrow, centered */}
158
+ <div className="flex sm:hidden flex-col items-center gap-1 w-full">
159
+ <DateTimePicker
160
+ value={value.startDate}
161
+ onChange={(startDate) => {
162
+ if (startDate && !disabled) {
163
+ handleCustomDateChange({ ...value, startDate });
164
+ }
165
+ }}
166
+ maxDate={value.endDate}
167
+ disabled={disabled}
168
+ />
169
+ <span className="text-sm text-muted-foreground">↓</span>
170
+ <DateTimePicker
171
+ value={value.endDate}
172
+ onChange={(endDate) => {
173
+ if (endDate && !disabled) {
174
+ handleCustomDateChange({ ...value, endDate });
175
+ }
176
+ }}
177
+ minDate={value.startDate}
178
+ maxDate={new Date()}
179
+ disabled={disabled}
180
+ />
181
+ </div>
182
+ </>
183
+ )}
109
184
  </div>
110
-
111
- {showCustom && (
112
- <div className="flex items-center gap-2 flex-wrap">
113
- <span className="text-sm text-muted-foreground">From:</span>
114
- <DateTimePicker
115
- value={value.startDate}
116
- onChange={(startDate) => {
117
- if (startDate) {
118
- onChange({ ...value, startDate });
119
- }
120
- }}
121
- maxDate={value.endDate}
122
- />
123
- <span className="text-sm text-muted-foreground">To:</span>
124
- <DateTimePicker
125
- value={value.endDate}
126
- onChange={(endDate) => {
127
- if (endDate) {
128
- onChange({ ...value, endDate });
129
- }
130
- }}
131
- minDate={value.startDate}
132
- maxDate={new Date()}
133
- />
134
- </div>
185
+ {isInvalidRange && (
186
+ <p className="text-sm text-destructive mt-2">
187
+ Start date must be before end date
188
+ </p>
135
189
  )}
136
190
  </div>
137
191
  );
@@ -10,6 +10,8 @@ export interface DateTimePickerProps {
10
10
  onChange: (date: Date | undefined) => void;
11
11
  minDate?: Date;
12
12
  maxDate?: Date;
13
+ /** Disable all interactions */
14
+ disabled?: boolean;
13
15
  className?: string;
14
16
  }
15
17
 
@@ -37,6 +39,7 @@ export const DateTimePicker: React.FC<DateTimePickerProps> = ({
37
39
  onChange,
38
40
  minDate,
39
41
  maxDate,
42
+ disabled = false,
40
43
  className,
41
44
  }) => {
42
45
  const isValidDate = value instanceof Date && !Number.isNaN(value.getTime());
@@ -258,6 +261,7 @@ export const DateTimePicker: React.FC<DateTimePickerProps> = ({
258
261
  size="icon"
259
262
  className="h-9 w-9 rounded-none border-r"
260
263
  type="button"
264
+ disabled={disabled}
261
265
  >
262
266
  <Calendar className="h-4 w-4" />
263
267
  </Button>
@@ -271,8 +275,9 @@ export const DateTimePicker: React.FC<DateTimePickerProps> = ({
271
275
  onChange={(e) => handleFieldChange("day", e.target.value)}
272
276
  onBlur={() => handleFieldBlur("day")}
273
277
  placeholder="DD"
274
- className="w-7 text-center bg-transparent border-none outline-none text-sm font-mono"
278
+ className="w-7 text-center bg-transparent border-none outline-none text-sm font-mono disabled:opacity-50"
275
279
  maxLength={2}
280
+ disabled={disabled}
276
281
  />
277
282
  <span className="text-muted-foreground">/</span>
278
283
  <input
@@ -282,8 +287,9 @@ export const DateTimePicker: React.FC<DateTimePickerProps> = ({
282
287
  onChange={(e) => handleFieldChange("month", e.target.value)}
283
288
  onBlur={() => handleFieldBlur("month")}
284
289
  placeholder="MM"
285
- className="w-7 text-center bg-transparent border-none outline-none text-sm font-mono"
290
+ className="w-7 text-center bg-transparent border-none outline-none text-sm font-mono disabled:opacity-50"
286
291
  maxLength={2}
292
+ disabled={disabled}
287
293
  />
288
294
  <span className="text-muted-foreground">/</span>
289
295
  <input
@@ -293,8 +299,9 @@ export const DateTimePicker: React.FC<DateTimePickerProps> = ({
293
299
  onChange={(e) => handleFieldChange("year", e.target.value)}
294
300
  onBlur={() => handleFieldBlur("year")}
295
301
  placeholder="YYYY"
296
- className="w-11 text-center bg-transparent border-none outline-none text-sm font-mono"
302
+ className="w-11 text-center bg-transparent border-none outline-none text-sm font-mono disabled:opacity-50"
297
303
  maxLength={4}
304
+ disabled={disabled}
298
305
  />
299
306
  </div>
300
307
 
@@ -337,8 +344,9 @@ export const DateTimePicker: React.FC<DateTimePickerProps> = ({
337
344
  onChange={(e) => handleFieldChange("hour", e.target.value)}
338
345
  onBlur={() => handleFieldBlur("hour")}
339
346
  placeholder="HH"
340
- className="w-6 text-center bg-transparent border-none outline-none text-sm font-mono"
347
+ className="w-6 text-center bg-transparent border-none outline-none text-sm font-mono disabled:opacity-50"
341
348
  maxLength={2}
349
+ disabled={disabled}
342
350
  />
343
351
  <span className="text-muted-foreground">:</span>
344
352
  <input
@@ -348,8 +356,9 @@ export const DateTimePicker: React.FC<DateTimePickerProps> = ({
348
356
  onChange={(e) => handleFieldChange("minute", e.target.value)}
349
357
  onBlur={() => handleFieldBlur("minute")}
350
358
  placeholder="MM"
351
- className="w-6 text-center bg-transparent border-none outline-none text-sm font-mono"
359
+ className="w-6 text-center bg-transparent border-none outline-none text-sm font-mono disabled:opacity-50"
352
360
  maxLength={2}
361
+ disabled={disabled}
353
362
  />
354
363
  </div>
355
364
  </div>
@@ -1,4 +1,5 @@
1
1
  import React from "react";
2
+ import type { LucideIcon } from "lucide-react";
2
3
  import { cn } from "../utils";
3
4
 
4
5
  interface PageProps extends React.HTMLAttributes<HTMLDivElement> {
@@ -14,33 +15,37 @@ export const Page = React.forwardRef<HTMLDivElement, PageProps>(
14
15
  >
15
16
  {children}
16
17
  </div>
17
- )
18
+ ),
18
19
  );
19
20
  Page.displayName = "Page";
20
21
 
21
22
  interface PageHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
22
23
  title: string;
23
24
  subtitle?: string;
25
+ icon: LucideIcon;
24
26
  actions?: React.ReactNode;
25
27
  }
26
28
 
27
29
  export const PageHeader = React.forwardRef<HTMLDivElement, PageHeaderProps>(
28
- ({ className, title, subtitle, actions, ...props }, ref) => (
30
+ ({ className, title, subtitle, icon: Icon, actions, ...props }, ref) => (
29
31
  <div
30
32
  ref={ref}
31
33
  className={cn(
32
- "flex flex-col md:flex-row items-center justify-between p-6 pb-2",
33
- className
34
+ "flex flex-col md:flex-row items-center justify-between py-3 pb-2 md:py-6 md:pb-2",
35
+ className,
34
36
  )}
35
37
  {...props}
36
38
  >
37
39
  <div className="space-y-1">
38
- <h2 className="text-2xl font-bold tracking-tight">{title}</h2>
40
+ <div className="flex items-center gap-3">
41
+ <Icon className="h-6 w-6 text-primary" />
42
+ <h2 className="text-2xl font-bold tracking-tight">{title}</h2>
43
+ </div>
39
44
  {subtitle && <p className="text-muted-foreground">{subtitle}</p>}
40
45
  </div>
41
46
  {actions && <div className="flex items-center space-x-2">{actions}</div>}
42
47
  </div>
43
- )
48
+ ),
44
49
  );
45
50
  PageHeader.displayName = "PageHeader";
46
51
 
@@ -50,9 +55,13 @@ interface PageContentProps extends React.HTMLAttributes<HTMLDivElement> {
50
55
 
51
56
  export const PageContent = React.forwardRef<HTMLDivElement, PageContentProps>(
52
57
  ({ className, children, ...props }, ref) => (
53
- <div ref={ref} className={cn("flex-1 p-6 pt-2", className)} {...props}>
58
+ <div
59
+ ref={ref}
60
+ className={cn("flex-1 py-3 pt-2 md:py-6 md:pt-2", className)}
61
+ {...props}
62
+ >
54
63
  {children}
55
64
  </div>
56
- )
65
+ ),
57
66
  );
58
67
  PageContent.displayName = "PageContent";
@@ -1,4 +1,5 @@
1
1
  import React from "react";
2
+ import type { LucideIcon } from "lucide-react";
2
3
  import {
3
4
  Page,
4
5
  PageHeader,
@@ -10,6 +11,7 @@ import {
10
11
  interface PageLayoutProps {
11
12
  title: string;
12
13
  subtitle?: string;
14
+ icon: LucideIcon;
13
15
  actions?: React.ReactNode;
14
16
  loading?: boolean;
15
17
  allowed?: boolean;
@@ -31,6 +33,7 @@ interface PageLayoutProps {
31
33
  export const PageLayout: React.FC<PageLayoutProps> = ({
32
34
  title,
33
35
  subtitle,
36
+ icon,
34
37
  actions,
35
38
  loading,
36
39
  allowed,
@@ -46,7 +49,12 @@ export const PageLayout: React.FC<PageLayoutProps> = ({
46
49
  if (isLoading) {
47
50
  return (
48
51
  <Page>
49
- <PageHeader title={title} subtitle={subtitle} actions={actions} />
52
+ <PageHeader
53
+ title={title}
54
+ subtitle={subtitle}
55
+ icon={icon}
56
+ actions={actions}
57
+ />
50
58
  <PageContent>
51
59
  <div className="flex justify-center py-12">
52
60
  <LoadingSpinner />
@@ -60,7 +68,12 @@ export const PageLayout: React.FC<PageLayoutProps> = ({
60
68
  if (allowed === false) {
61
69
  return (
62
70
  <Page>
63
- <PageHeader title={title} subtitle={subtitle} actions={actions} />
71
+ <PageHeader
72
+ title={title}
73
+ subtitle={subtitle}
74
+ icon={icon}
75
+ actions={actions}
76
+ />
64
77
  <PageContent>
65
78
  <AccessDenied />
66
79
  </PageContent>
@@ -70,10 +83,17 @@ export const PageLayout: React.FC<PageLayoutProps> = ({
70
83
 
71
84
  return (
72
85
  <Page>
73
- <PageHeader title={title} subtitle={subtitle} actions={actions} />
86
+ <PageHeader
87
+ title={title}
88
+ subtitle={subtitle}
89
+ icon={icon}
90
+ actions={actions}
91
+ />
74
92
  <PageContent>
75
93
  <div
76
- className={maxWidth === "full" ? "" : `max-w-${maxWidth} space-y-6`}
94
+ className={
95
+ maxWidth === "full" ? "space-y-6" : `max-w-${maxWidth} space-y-6`
96
+ }
77
97
  >
78
98
  {children}
79
99
  </div>