@wakastellar/ui 2.1.2 → 2.3.2

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 (123) hide show
  1. package/dist/blocks/apm-overview/index.d.ts +58 -0
  2. package/dist/blocks/cicd-builder/index.d.ts +47 -0
  3. package/dist/blocks/cloud-cost-dashboard/index.d.ts +49 -0
  4. package/dist/blocks/container-orchestrator/index.d.ts +63 -0
  5. package/dist/blocks/database-admin/index.d.ts +84 -0
  6. package/dist/blocks/gitops-sync-status/index.d.ts +45 -0
  7. package/dist/blocks/incident-manager/index.d.ts +44 -0
  8. package/dist/blocks/index.d.ts +10 -0
  9. package/dist/blocks/infrastructure-map/index.d.ts +32 -0
  10. package/dist/blocks/on-call-schedule/index.d.ts +43 -0
  11. package/dist/blocks/release-notes/index.d.ts +49 -0
  12. package/dist/components/index.d.ts +34 -0
  13. package/dist/components/waka-ad-banner/index.d.ts +36 -0
  14. package/dist/components/waka-ad-fallback/index.d.ts +33 -0
  15. package/dist/components/waka-ad-inline/index.d.ts +15 -0
  16. package/dist/components/waka-ad-interstitial/index.d.ts +26 -0
  17. package/dist/components/waka-ad-placeholder/index.d.ts +17 -0
  18. package/dist/components/waka-ad-provider/index.d.ts +103 -0
  19. package/dist/components/waka-ad-sidebar/index.d.ts +18 -0
  20. package/dist/components/waka-ad-sticky-footer/index.d.ts +17 -0
  21. package/dist/components/waka-alert-panel/index.d.ts +45 -0
  22. package/dist/components/waka-artifact-list/index.d.ts +32 -0
  23. package/dist/components/waka-build-matrix/index.d.ts +36 -0
  24. package/dist/components/waka-config-comparator/index.d.ts +37 -0
  25. package/dist/components/waka-container-list/index.d.ts +51 -0
  26. package/dist/components/waka-content-recommendation/index.d.ts +23 -0
  27. package/dist/components/waka-database-card/index.d.ts +46 -0
  28. package/dist/components/waka-dependency-tree/index.d.ts +38 -0
  29. package/dist/components/waka-env-var-editor/index.d.ts +30 -0
  30. package/dist/components/waka-feature-flag-row/index.d.ts +45 -0
  31. package/dist/components/waka-kubernetes-overview/index.d.ts +98 -0
  32. package/dist/components/waka-log-viewer/index.d.ts +38 -0
  33. package/dist/components/waka-migration-list/index.d.ts +36 -0
  34. package/dist/components/waka-outstream-video/index.d.ts +24 -0
  35. package/dist/components/waka-pod-card/index.d.ts +73 -0
  36. package/dist/components/waka-query-explain/index.d.ts +48 -0
  37. package/dist/components/waka-secret-card/index.d.ts +43 -0
  38. package/dist/components/waka-security-scan-result/index.d.ts +45 -0
  39. package/dist/components/waka-service-graph/index.d.ts +44 -0
  40. package/dist/components/waka-sponsored-badge/index.d.ts +20 -0
  41. package/dist/components/waka-sponsored-card/index.d.ts +25 -0
  42. package/dist/components/waka-sponsored-feed/index.d.ts +31 -0
  43. package/dist/components/waka-test-report/index.d.ts +60 -0
  44. package/dist/components/waka-trace-viewer/index.d.ts +36 -0
  45. package/dist/components/waka-video-ad/index.d.ts +32 -0
  46. package/dist/components/waka-video-overlay/index.d.ts +26 -0
  47. package/dist/index.cjs.js +251 -200
  48. package/dist/index.d.ts +1 -0
  49. package/dist/index.es.js +47315 -35823
  50. package/dist/utils/security.d.ts +96 -0
  51. package/package.json +4 -4
  52. package/src/blocks/apm-overview/index.tsx +672 -0
  53. package/src/blocks/cicd-builder/index.tsx +738 -0
  54. package/src/blocks/cloud-cost-dashboard/index.tsx +597 -0
  55. package/src/blocks/container-orchestrator/index.tsx +729 -0
  56. package/src/blocks/database-admin/index.tsx +679 -0
  57. package/src/blocks/gitops-sync-status/index.tsx +557 -0
  58. package/src/blocks/incident-manager/index.tsx +586 -0
  59. package/src/blocks/index.ts +119 -0
  60. package/src/blocks/infrastructure-map/index.tsx +638 -0
  61. package/src/blocks/on-call-schedule/index.tsx +615 -0
  62. package/src/blocks/release-notes/index.tsx +643 -0
  63. package/src/blocks/sidebar/index.tsx +6 -6
  64. package/src/components/DataTable/templates/index.tsx +3 -2
  65. package/src/components/index.ts +283 -0
  66. package/src/components/waka-3d-pie-chart/index.tsx +11 -11
  67. package/src/components/waka-achievement-unlock/index.tsx +16 -16
  68. package/src/components/waka-ad-banner/index.tsx +275 -0
  69. package/src/components/waka-ad-fallback/index.tsx +181 -0
  70. package/src/components/waka-ad-inline/index.tsx +103 -0
  71. package/src/components/waka-ad-interstitial/index.tsx +278 -0
  72. package/src/components/waka-ad-placeholder/index.tsx +84 -0
  73. package/src/components/waka-ad-provider/index.tsx +329 -0
  74. package/src/components/waka-ad-sidebar/index.tsx +113 -0
  75. package/src/components/waka-ad-sticky-footer/index.tsx +125 -0
  76. package/src/components/waka-alert-panel/index.tsx +493 -0
  77. package/src/components/waka-artifact-list/index.tsx +416 -0
  78. package/src/components/waka-badge-showcase/index.tsx +12 -11
  79. package/src/components/waka-build-matrix/index.tsx +396 -0
  80. package/src/components/waka-command-bar/index.tsx +2 -1
  81. package/src/components/waka-config-comparator/index.tsx +416 -0
  82. package/src/components/waka-container-list/index.tsx +475 -0
  83. package/src/components/waka-content-recommendation/index.tsx +294 -0
  84. package/src/components/waka-cost-breakdown/index.tsx +10 -10
  85. package/src/components/waka-database-card/index.tsx +473 -0
  86. package/src/components/waka-dependency-tree/index.tsx +542 -0
  87. package/src/components/waka-env-var-editor/index.tsx +417 -0
  88. package/src/components/waka-feature-flag-row/index.tsx +386 -0
  89. package/src/components/waka-funnel-chart/index.tsx +8 -8
  90. package/src/components/waka-health-pulse/index.tsx +6 -6
  91. package/src/components/waka-kubernetes-overview/index.tsx +536 -0
  92. package/src/components/waka-leaderboard/index.tsx +9 -9
  93. package/src/components/waka-log-viewer/index.tsx +386 -0
  94. package/src/components/waka-loot-box/index.tsx +20 -20
  95. package/src/components/waka-migration-list/index.tsx +487 -0
  96. package/src/components/waka-outstream-video/index.tsx +240 -0
  97. package/src/components/waka-player-card/index.tsx +5 -5
  98. package/src/components/waka-pod-card/index.tsx +528 -0
  99. package/src/components/waka-query-explain/index.tsx +657 -0
  100. package/src/components/waka-quota-bar/index.tsx +4 -4
  101. package/src/components/waka-radar-score/index.tsx +10 -10
  102. package/src/components/waka-scratch-card/index.tsx +5 -4
  103. package/src/components/waka-secret-card/index.tsx +371 -0
  104. package/src/components/waka-security-scan-result/index.tsx +473 -0
  105. package/src/components/waka-server-rack/index.tsx +28 -27
  106. package/src/components/waka-service-graph/index.tsx +445 -0
  107. package/src/components/waka-sponsored-badge/index.tsx +97 -0
  108. package/src/components/waka-sponsored-card/index.tsx +275 -0
  109. package/src/components/waka-sponsored-feed/index.tsx +127 -0
  110. package/src/components/waka-spotlight/index.tsx +2 -1
  111. package/src/components/waka-success-explosion/index.tsx +4 -4
  112. package/src/components/waka-test-report/index.tsx +469 -0
  113. package/src/components/waka-trace-viewer/index.tsx +490 -0
  114. package/src/components/waka-video-ad/index.tsx +406 -0
  115. package/src/components/waka-video-overlay/index.tsx +257 -0
  116. package/src/components/waka-xp-bar/index.tsx +13 -13
  117. package/src/styles/base.css +16 -0
  118. package/src/styles/tailwind.preset.js +12 -0
  119. package/src/styles/themes/forest.css +16 -0
  120. package/src/styles/themes/monochrome.css +16 -0
  121. package/src/styles/themes/perpetuity.css +16 -0
  122. package/src/styles/themes/sunset.css +16 -0
  123. package/src/styles/themes/twilight.css +16 -0
@@ -0,0 +1,615 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { cn } from "../../utils/cn"
5
+ import { Badge } from "../../components/badge"
6
+ import { Button } from "../../components/button"
7
+ import { Card, CardContent, CardHeader, CardTitle } from "../../components/card"
8
+ import { Avatar, AvatarFallback, AvatarImage } from "../../components/avatar"
9
+ import { ScrollArea } from "../../components/scroll-area"
10
+ import {
11
+ Select,
12
+ SelectContent,
13
+ SelectItem,
14
+ SelectTrigger,
15
+ SelectValue,
16
+ } from "../../components/select"
17
+ import {
18
+ Phone,
19
+ Calendar,
20
+ Clock,
21
+ Users,
22
+ ChevronLeft,
23
+ ChevronRight,
24
+ AlertCircle,
25
+ Bell,
26
+ User,
27
+ Shield,
28
+ Repeat,
29
+ Plus,
30
+ Settings,
31
+ Mail,
32
+ MessageSquare,
33
+ ArrowRight,
34
+ CheckCircle2,
35
+ XCircle,
36
+ } from "lucide-react"
37
+
38
+ export type RotationType = "daily" | "weekly" | "biweekly" | "monthly" | "custom"
39
+ export type EscalationLevel = 1 | 2 | 3
40
+
41
+ export interface OnCallUser {
42
+ id: string
43
+ name: string
44
+ email: string
45
+ phone?: string
46
+ avatar?: string
47
+ timezone?: string
48
+ status?: "available" | "busy" | "dnd" | "offline"
49
+ }
50
+
51
+ export interface OnCallShift {
52
+ id: string
53
+ user: OnCallUser
54
+ startTime: Date
55
+ endTime: Date
56
+ level: EscalationLevel
57
+ overrideReason?: string
58
+ }
59
+
60
+ export interface OnCallSchedule {
61
+ id: string
62
+ name: string
63
+ description?: string
64
+ team: string
65
+ rotation: RotationType
66
+ escalationPolicy: {
67
+ level1Timeout: number // minutes
68
+ level2Timeout: number // minutes
69
+ level3Timeout: number // minutes
70
+ }
71
+ shifts: OnCallShift[]
72
+ users: OnCallUser[]
73
+ }
74
+
75
+ export interface OnCallScheduleBlockProps {
76
+ schedule: OnCallSchedule
77
+ onEditSchedule?: () => void
78
+ onAddOverride?: (date: Date) => void
79
+ onSwapShift?: (shift: OnCallShift) => void
80
+ onContactOnCall?: (user: OnCallUser) => void
81
+ className?: string
82
+ }
83
+
84
+ const levelConfig: Record<EscalationLevel, { label: string; color: string; bgColor: string }> = {
85
+ 1: { label: "Primary", color: "text-green-500", bgColor: "bg-green-500" },
86
+ 2: { label: "Secondary", color: "text-yellow-500", bgColor: "bg-yellow-500" },
87
+ 3: { label: "Tertiary", color: "text-red-500", bgColor: "bg-red-500" },
88
+ }
89
+
90
+ const statusConfig = {
91
+ available: { color: "bg-green-500", label: "Available" },
92
+ busy: { color: "bg-yellow-500", label: "Busy" },
93
+ dnd: { color: "bg-red-500", label: "Do Not Disturb" },
94
+ offline: { color: "bg-gray-500", label: "Offline" },
95
+ }
96
+
97
+ function formatTimeRange(start: Date, end: Date): string {
98
+ const formatTime = (d: Date) => d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })
99
+ const formatDate = (d: Date) => d.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" })
100
+
101
+ const sameDay = start.toDateString() === end.toDateString()
102
+ if (sameDay) {
103
+ return `${formatDate(start)} ${formatTime(start)} - ${formatTime(end)}`
104
+ }
105
+ return `${formatDate(start)} ${formatTime(start)} - ${formatDate(end)} ${formatTime(end)}`
106
+ }
107
+
108
+ function isCurrentShift(shift: OnCallShift): boolean {
109
+ const now = new Date()
110
+ return shift.startTime <= now && shift.endTime > now
111
+ }
112
+
113
+ function CurrentOnCallCard({
114
+ shift,
115
+ nextShift,
116
+ onContact,
117
+ }: {
118
+ shift?: OnCallShift
119
+ nextShift?: OnCallShift
120
+ onContact?: (user: OnCallUser) => void
121
+ }) {
122
+ if (!shift) {
123
+ return (
124
+ <Card className="border-dashed">
125
+ <CardContent className="p-6 text-center text-muted-foreground">
126
+ <AlertCircle className="h-8 w-8 mx-auto mb-2" />
127
+ <p>No one is currently on call</p>
128
+ </CardContent>
129
+ </Card>
130
+ )
131
+ }
132
+
133
+ const levelConf = levelConfig[shift.level]
134
+ const statusConf = shift.user.status ? statusConfig[shift.user.status] : null
135
+ const timeRemaining = shift.endTime.getTime() - Date.now()
136
+ const hoursRemaining = Math.floor(timeRemaining / 3600000)
137
+ const minutesRemaining = Math.floor((timeRemaining % 3600000) / 60000)
138
+
139
+ return (
140
+ <Card className="border-green-500/30 bg-green-500/5">
141
+ <CardHeader className="pb-2">
142
+ <div className="flex items-center justify-between">
143
+ <Badge className={cn("text-white", levelConf.bgColor)}>
144
+ {levelConf.label} On-Call
145
+ </Badge>
146
+ <span className="text-sm text-muted-foreground flex items-center gap-1">
147
+ <Clock className="h-4 w-4" />
148
+ {hoursRemaining}h {minutesRemaining}m remaining
149
+ </span>
150
+ </div>
151
+ </CardHeader>
152
+ <CardContent className="space-y-4">
153
+ <div className="flex items-center gap-4">
154
+ <div className="relative">
155
+ <Avatar className="h-16 w-16">
156
+ <AvatarImage src={shift.user.avatar} />
157
+ <AvatarFallback className="text-xl">
158
+ {shift.user.name.slice(0, 2).toUpperCase()}
159
+ </AvatarFallback>
160
+ </Avatar>
161
+ {statusConf && (
162
+ <div className={cn(
163
+ "absolute bottom-0 right-0 w-4 h-4 rounded-full border-2 border-background",
164
+ statusConf.color
165
+ )} />
166
+ )}
167
+ </div>
168
+
169
+ <div className="flex-1">
170
+ <div className="text-xl font-semibold">{shift.user.name}</div>
171
+ <div className="text-sm text-muted-foreground">{shift.user.email}</div>
172
+ {shift.user.timezone && (
173
+ <div className="text-xs text-muted-foreground mt-1">
174
+ Timezone: {shift.user.timezone}
175
+ </div>
176
+ )}
177
+ </div>
178
+
179
+ <div className="flex flex-col gap-2">
180
+ {shift.user.phone && (
181
+ <Button size="sm" variant="outline" onClick={() => onContact?.(shift.user)}>
182
+ <Phone className="h-4 w-4 mr-1" />
183
+ Call
184
+ </Button>
185
+ )}
186
+ <Button size="sm" variant="outline" onClick={() => onContact?.(shift.user)}>
187
+ <MessageSquare className="h-4 w-4 mr-1" />
188
+ Message
189
+ </Button>
190
+ </div>
191
+ </div>
192
+
193
+ {shift.overrideReason && (
194
+ <div className="text-sm text-muted-foreground bg-muted/50 p-2 rounded">
195
+ <span className="font-medium">Override:</span> {shift.overrideReason}
196
+ </div>
197
+ )}
198
+
199
+ {nextShift && (
200
+ <div className="flex items-center gap-2 pt-2 border-t text-sm">
201
+ <ArrowRight className="h-4 w-4 text-muted-foreground" />
202
+ <span className="text-muted-foreground">Next:</span>
203
+ <Avatar className="h-5 w-5">
204
+ <AvatarImage src={nextShift.user.avatar} />
205
+ <AvatarFallback className="text-xs">
206
+ {nextShift.user.name.slice(0, 2).toUpperCase()}
207
+ </AvatarFallback>
208
+ </Avatar>
209
+ <span>{nextShift.user.name}</span>
210
+ <span className="text-muted-foreground">
211
+ at {nextShift.startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}
212
+ </span>
213
+ </div>
214
+ )}
215
+ </CardContent>
216
+ </Card>
217
+ )
218
+ }
219
+
220
+ function EscalationPolicyCard({
221
+ policy,
222
+ currentLevel,
223
+ }: {
224
+ policy: OnCallSchedule["escalationPolicy"]
225
+ currentLevel?: EscalationLevel
226
+ }) {
227
+ return (
228
+ <Card>
229
+ <CardHeader className="pb-2">
230
+ <CardTitle className="text-base flex items-center gap-2">
231
+ <Shield className="h-4 w-4" />
232
+ Escalation Policy
233
+ </CardTitle>
234
+ </CardHeader>
235
+ <CardContent>
236
+ <div className="space-y-3">
237
+ {([1, 2, 3] as EscalationLevel[]).map((level) => {
238
+ const conf = levelConfig[level]
239
+ const timeout = level === 1 ? policy.level1Timeout : level === 2 ? policy.level2Timeout : policy.level3Timeout
240
+ const isActive = currentLevel === level
241
+
242
+ return (
243
+ <div
244
+ key={level}
245
+ className={cn(
246
+ "flex items-center gap-3 p-2 rounded",
247
+ isActive && `${conf.bgColor}/10 border ${conf.bgColor}/30`
248
+ )}
249
+ >
250
+ <div className={cn("w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-bold", conf.bgColor)}>
251
+ {level}
252
+ </div>
253
+ <div className="flex-1">
254
+ <div className="font-medium">{conf.label}</div>
255
+ <div className="text-xs text-muted-foreground">
256
+ Escalates after {timeout} minutes
257
+ </div>
258
+ </div>
259
+ {isActive && (
260
+ <Badge variant="outline" className={conf.color}>
261
+ Active
262
+ </Badge>
263
+ )}
264
+ </div>
265
+ )
266
+ })}
267
+ </div>
268
+ </CardContent>
269
+ </Card>
270
+ )
271
+ }
272
+
273
+ function WeekView({
274
+ shifts,
275
+ weekStart,
276
+ onShiftClick,
277
+ }: {
278
+ shifts: OnCallShift[]
279
+ weekStart: Date
280
+ onShiftClick?: (shift: OnCallShift) => void
281
+ }) {
282
+ const days = React.useMemo(() => {
283
+ const result = []
284
+ for (let i = 0; i < 7; i++) {
285
+ const date = new Date(weekStart)
286
+ date.setDate(date.getDate() + i)
287
+ result.push(date)
288
+ }
289
+ return result
290
+ }, [weekStart])
291
+
292
+ const getShiftsForDay = (date: Date) => {
293
+ return shifts.filter((shift) => {
294
+ const shiftDate = new Date(shift.startTime)
295
+ return shiftDate.toDateString() === date.toDateString()
296
+ })
297
+ }
298
+
299
+ const isToday = (date: Date) => date.toDateString() === new Date().toDateString()
300
+
301
+ return (
302
+ <div className="grid grid-cols-7 gap-2">
303
+ {days.map((day) => {
304
+ const dayShifts = getShiftsForDay(day)
305
+ const today = isToday(day)
306
+
307
+ return (
308
+ <div
309
+ key={day.toISOString()}
310
+ className={cn(
311
+ "border rounded-lg p-2 min-h-[120px]",
312
+ today && "border-primary bg-primary/5"
313
+ )}
314
+ >
315
+ <div className={cn(
316
+ "text-center mb-2",
317
+ today && "font-bold text-primary"
318
+ )}>
319
+ <div className="text-xs text-muted-foreground">
320
+ {day.toLocaleDateString("en-US", { weekday: "short" })}
321
+ </div>
322
+ <div className={cn("text-lg", today && "text-primary")}>
323
+ {day.getDate()}
324
+ </div>
325
+ </div>
326
+
327
+ <div className="space-y-1">
328
+ {dayShifts.map((shift) => {
329
+ const conf = levelConfig[shift.level]
330
+ const isCurrent = isCurrentShift(shift)
331
+
332
+ return (
333
+ <button
334
+ key={shift.id}
335
+ className={cn(
336
+ "w-full text-left p-1.5 rounded text-xs transition-colors",
337
+ `${conf.bgColor}/10 hover:${conf.bgColor}/20`,
338
+ isCurrent && `ring-2 ring-${conf.bgColor}`
339
+ )}
340
+ onClick={() => onShiftClick?.(shift)}
341
+ >
342
+ <div className="flex items-center gap-1">
343
+ <Avatar className="h-4 w-4">
344
+ <AvatarImage src={shift.user.avatar} />
345
+ <AvatarFallback className="text-[8px]">
346
+ {shift.user.name.slice(0, 2).toUpperCase()}
347
+ </AvatarFallback>
348
+ </Avatar>
349
+ <span className="truncate">{shift.user.name.split(" ")[0]}</span>
350
+ </div>
351
+ <div className="text-muted-foreground mt-0.5">
352
+ {shift.startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}
353
+ </div>
354
+ </button>
355
+ )
356
+ })}
357
+ </div>
358
+ </div>
359
+ )
360
+ })}
361
+ </div>
362
+ )
363
+ }
364
+
365
+ function TeamMembers({
366
+ users,
367
+ shifts,
368
+ }: {
369
+ users: OnCallUser[]
370
+ shifts: OnCallShift[]
371
+ }) {
372
+ // Calculate on-call hours for each user
373
+ const userStats = React.useMemo(() => {
374
+ return users.map((user) => {
375
+ const userShifts = shifts.filter((s) => s.user.id === user.id)
376
+ const totalHours = userShifts.reduce((acc, s) => {
377
+ return acc + (s.endTime.getTime() - s.startTime.getTime()) / 3600000
378
+ }, 0)
379
+ const isOnCall = userShifts.some(isCurrentShift)
380
+ return { user, totalHours, shiftCount: userShifts.length, isOnCall }
381
+ })
382
+ }, [users, shifts])
383
+
384
+ return (
385
+ <Card>
386
+ <CardHeader className="pb-2">
387
+ <CardTitle className="text-base flex items-center gap-2">
388
+ <Users className="h-4 w-4" />
389
+ Team Members
390
+ </CardTitle>
391
+ </CardHeader>
392
+ <CardContent>
393
+ <div className="space-y-2">
394
+ {userStats.map(({ user, totalHours, shiftCount, isOnCall }) => {
395
+ const statusConf = user.status ? statusConfig[user.status] : null
396
+
397
+ return (
398
+ <div
399
+ key={user.id}
400
+ className={cn(
401
+ "flex items-center gap-3 p-2 rounded",
402
+ isOnCall && "bg-green-500/10 border border-green-500/30"
403
+ )}
404
+ >
405
+ <div className="relative">
406
+ <Avatar className="h-8 w-8">
407
+ <AvatarImage src={user.avatar} />
408
+ <AvatarFallback className="text-xs">
409
+ {user.name.slice(0, 2).toUpperCase()}
410
+ </AvatarFallback>
411
+ </Avatar>
412
+ {statusConf && (
413
+ <div className={cn(
414
+ "absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-background",
415
+ statusConf.color
416
+ )} />
417
+ )}
418
+ </div>
419
+
420
+ <div className="flex-1 min-w-0">
421
+ <div className="font-medium truncate flex items-center gap-2">
422
+ {user.name}
423
+ {isOnCall && (
424
+ <Badge className="bg-green-500 text-xs">On-Call</Badge>
425
+ )}
426
+ </div>
427
+ <div className="text-xs text-muted-foreground">
428
+ {shiftCount} shifts • {Math.round(totalHours)}h total
429
+ </div>
430
+ </div>
431
+ </div>
432
+ )
433
+ })}
434
+ </div>
435
+ </CardContent>
436
+ </Card>
437
+ )
438
+ }
439
+
440
+ export function OnCallScheduleBlock({
441
+ schedule,
442
+ onEditSchedule,
443
+ onAddOverride,
444
+ onSwapShift,
445
+ onContactOnCall,
446
+ className,
447
+ }: OnCallScheduleBlockProps) {
448
+ const [weekOffset, setWeekOffset] = React.useState(0)
449
+
450
+ const weekStart = React.useMemo(() => {
451
+ const now = new Date()
452
+ const dayOfWeek = now.getDay()
453
+ const start = new Date(now)
454
+ start.setDate(now.getDate() - dayOfWeek + (weekOffset * 7))
455
+ start.setHours(0, 0, 0, 0)
456
+ return start
457
+ }, [weekOffset])
458
+
459
+ // Find current on-call
460
+ const currentShift = schedule.shifts.find(isCurrentShift)
461
+ const upcomingShifts = schedule.shifts
462
+ .filter((s) => s.startTime > new Date())
463
+ .sort((a, b) => a.startTime.getTime() - b.startTime.getTime())
464
+ const nextShift = upcomingShifts[0]
465
+
466
+ return (
467
+ <div className={cn("space-y-6", className)}>
468
+ {/* Header */}
469
+ <Card>
470
+ <CardHeader>
471
+ <div className="flex items-center justify-between">
472
+ <div className="flex items-center gap-3">
473
+ <Phone className="h-6 w-6" />
474
+ <div>
475
+ <CardTitle>{schedule.name}</CardTitle>
476
+ <div className="flex items-center gap-2 mt-1">
477
+ <Badge variant="outline">{schedule.team}</Badge>
478
+ <Badge variant="secondary">
479
+ <Repeat className="h-3 w-3 mr-1" />
480
+ {schedule.rotation}
481
+ </Badge>
482
+ </div>
483
+ </div>
484
+ </div>
485
+
486
+ <div className="flex items-center gap-2">
487
+ {onAddOverride && (
488
+ <Button variant="outline" onClick={() => onAddOverride(new Date())}>
489
+ <Plus className="h-4 w-4 mr-1" />
490
+ Add Override
491
+ </Button>
492
+ )}
493
+ {onEditSchedule && (
494
+ <Button variant="outline" onClick={onEditSchedule}>
495
+ <Settings className="h-4 w-4 mr-1" />
496
+ Settings
497
+ </Button>
498
+ )}
499
+ </div>
500
+ </div>
501
+ </CardHeader>
502
+ </Card>
503
+
504
+ <div className="grid grid-cols-3 gap-6">
505
+ {/* Current on-call */}
506
+ <div className="col-span-2 space-y-4">
507
+ <CurrentOnCallCard
508
+ shift={currentShift}
509
+ nextShift={nextShift}
510
+ onContact={onContactOnCall}
511
+ />
512
+
513
+ {/* Week view */}
514
+ <Card>
515
+ <CardHeader className="pb-2">
516
+ <div className="flex items-center justify-between">
517
+ <CardTitle className="text-base flex items-center gap-2">
518
+ <Calendar className="h-4 w-4" />
519
+ Schedule
520
+ </CardTitle>
521
+ <div className="flex items-center gap-2">
522
+ <Button variant="ghost" size="sm" onClick={() => setWeekOffset((o) => o - 1)}>
523
+ <ChevronLeft className="h-4 w-4" />
524
+ </Button>
525
+ <span className="text-sm min-w-[120px] text-center">
526
+ {weekStart.toLocaleDateString("en-US", { month: "short", day: "numeric" })} -
527
+ {new Date(weekStart.getTime() + 6 * 24 * 3600000).toLocaleDateString("en-US", { month: "short", day: "numeric" })}
528
+ </span>
529
+ <Button variant="ghost" size="sm" onClick={() => setWeekOffset((o) => o + 1)}>
530
+ <ChevronRight className="h-4 w-4" />
531
+ </Button>
532
+ {weekOffset !== 0 && (
533
+ <Button variant="ghost" size="sm" onClick={() => setWeekOffset(0)}>
534
+ Today
535
+ </Button>
536
+ )}
537
+ </div>
538
+ </div>
539
+ </CardHeader>
540
+ <CardContent>
541
+ <WeekView
542
+ shifts={schedule.shifts}
543
+ weekStart={weekStart}
544
+ onShiftClick={onSwapShift}
545
+ />
546
+ </CardContent>
547
+ </Card>
548
+ </div>
549
+
550
+ {/* Sidebar */}
551
+ <div className="space-y-4">
552
+ <EscalationPolicyCard
553
+ policy={schedule.escalationPolicy}
554
+ currentLevel={currentShift?.level}
555
+ />
556
+ <TeamMembers users={schedule.users} shifts={schedule.shifts} />
557
+ </div>
558
+ </div>
559
+ </div>
560
+ )
561
+ }
562
+
563
+ // Default sample data
564
+ const sampleUsers: OnCallUser[] = [
565
+ { id: "1", name: "John Doe", email: "john@example.com", phone: "+1 555-0101", status: "available", timezone: "America/New_York" },
566
+ { id: "2", name: "Jane Smith", email: "jane@example.com", phone: "+1 555-0102", status: "available", timezone: "America/Los_Angeles" },
567
+ { id: "3", name: "Bob Wilson", email: "bob@example.com", phone: "+1 555-0103", status: "busy", timezone: "Europe/London" },
568
+ { id: "4", name: "Alice Brown", email: "alice@example.com", phone: "+1 555-0104", status: "available", timezone: "Europe/Paris" },
569
+ ]
570
+
571
+ function generateShifts(): OnCallShift[] {
572
+ const shifts: OnCallShift[] = []
573
+ const now = new Date()
574
+ const startOfWeek = new Date(now)
575
+ startOfWeek.setDate(now.getDate() - now.getDay())
576
+ startOfWeek.setHours(0, 0, 0, 0)
577
+
578
+ for (let week = -1; week < 3; week++) {
579
+ for (let day = 0; day < 7; day++) {
580
+ const shiftStart = new Date(startOfWeek)
581
+ shiftStart.setDate(startOfWeek.getDate() + (week * 7) + day)
582
+ shiftStart.setHours(9, 0, 0, 0)
583
+
584
+ const shiftEnd = new Date(shiftStart)
585
+ shiftEnd.setDate(shiftEnd.getDate() + 1)
586
+
587
+ const userIndex = (week + 1 + day) % sampleUsers.length
588
+
589
+ shifts.push({
590
+ id: `shift-${week}-${day}`,
591
+ user: sampleUsers[userIndex],
592
+ startTime: shiftStart,
593
+ endTime: shiftEnd,
594
+ level: 1,
595
+ })
596
+ }
597
+ }
598
+
599
+ return shifts
600
+ }
601
+
602
+ export const defaultOnCallSchedule: OnCallSchedule = {
603
+ id: "1",
604
+ name: "Platform Team On-Call",
605
+ description: "Primary on-call rotation for the platform engineering team",
606
+ team: "Platform Engineering",
607
+ rotation: "weekly",
608
+ escalationPolicy: {
609
+ level1Timeout: 15,
610
+ level2Timeout: 30,
611
+ level3Timeout: 60,
612
+ },
613
+ shifts: generateShifts(),
614
+ users: sampleUsers,
615
+ }