@flamingo-stack/openframe-frontend-core 0.0.277 → 0.0.278

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 (102) hide show
  1. package/dist/{chunk-LDEXTWMK.js → chunk-5IJ46KAV.js} +2 -1
  2. package/dist/{chunk-MHQPAA3W.js → chunk-5NL62LIF.js} +4 -4
  3. package/dist/{chunk-KBKW4LVO.js → chunk-737ZLQ2T.js} +3 -3
  4. package/dist/{chunk-LALAS7OH.cjs → chunk-7LTWNUHB.cjs} +1465 -1115
  5. package/dist/chunk-7LTWNUHB.cjs.map +1 -0
  6. package/dist/{chunk-2GLHJ7ZY.cjs → chunk-7XUBAK5K.cjs} +12 -12
  7. package/dist/{chunk-2GLHJ7ZY.cjs.map → chunk-7XUBAK5K.cjs.map} +1 -1
  8. package/dist/{chunk-L5P4IFPK.cjs → chunk-BBDJCXHW.cjs} +10 -10
  9. package/dist/{chunk-L5P4IFPK.cjs.map → chunk-BBDJCXHW.cjs.map} +1 -1
  10. package/dist/{chunk-NCJENC5G.cjs → chunk-DO55GWNT.cjs} +19 -19
  11. package/dist/{chunk-NCJENC5G.cjs.map → chunk-DO55GWNT.cjs.map} +1 -1
  12. package/dist/{chunk-P6Y3S6QK.js → chunk-GO45CXT5.js} +2239 -1889
  13. package/dist/chunk-GO45CXT5.js.map +1 -0
  14. package/dist/{chunk-LH7R56RR.cjs → chunk-HR4CVAVH.cjs} +20 -18
  15. package/dist/chunk-HR4CVAVH.cjs.map +1 -0
  16. package/dist/{chunk-ZSPPV2SX.cjs → chunk-L4BJU5CE.cjs} +38 -38
  17. package/dist/{chunk-ZSPPV2SX.cjs.map → chunk-L4BJU5CE.cjs.map} +1 -1
  18. package/dist/{chunk-DMAPXENZ.js → chunk-MDHNLZME.js} +4 -4
  19. package/dist/{chunk-6A5GGUIA.js → chunk-MVUN35FH.js} +3 -3
  20. package/dist/{chunk-UCX75VEM.js → chunk-R6SHY6ZQ.js} +2 -2
  21. package/dist/{chunk-ZS4XNIZK.js → chunk-RGCVYGA2.js} +5 -3
  22. package/dist/chunk-RGCVYGA2.js.map +1 -0
  23. package/dist/{chunk-27NKVNWD.cjs → chunk-WYQ5VFXA.cjs} +29 -29
  24. package/dist/{chunk-27NKVNWD.cjs.map → chunk-WYQ5VFXA.cjs.map} +1 -1
  25. package/dist/{chunk-N5UOJC3V.cjs → chunk-XXI7BNB6.cjs} +3 -2
  26. package/dist/chunk-XXI7BNB6.cjs.map +1 -0
  27. package/dist/components/chat/index.cjs +4 -4
  28. package/dist/components/chat/index.js +3 -3
  29. package/dist/components/contact/index.cjs +5 -5
  30. package/dist/components/contact/index.js +4 -4
  31. package/dist/components/faq/index.cjs +5 -5
  32. package/dist/components/faq/index.js +4 -4
  33. package/dist/components/features/index.cjs +16 -4
  34. package/dist/components/features/index.cjs.map +1 -1
  35. package/dist/components/features/index.d.ts +1 -0
  36. package/dist/components/features/index.d.ts.map +1 -1
  37. package/dist/components/features/index.js +16 -4
  38. package/dist/components/features/time-tracker/index.d.ts +10 -0
  39. package/dist/components/features/time-tracker/index.d.ts.map +1 -0
  40. package/dist/components/features/time-tracker/time-tracker-context.d.ts +17 -0
  41. package/dist/components/features/time-tracker/time-tracker-context.d.ts.map +1 -0
  42. package/dist/components/features/time-tracker/time-tracker-header-button.d.ts +13 -0
  43. package/dist/components/features/time-tracker/time-tracker-header-button.d.ts.map +1 -0
  44. package/dist/components/features/time-tracker/time-tracker-panel.d.ts +7 -0
  45. package/dist/components/features/time-tracker/time-tracker-panel.d.ts.map +1 -0
  46. package/dist/components/features/time-tracker/types.d.ts +54 -0
  47. package/dist/components/features/time-tracker/types.d.ts.map +1 -0
  48. package/dist/components/features/time-tracker/use-tracker-clock.d.ts +14 -0
  49. package/dist/components/features/time-tracker/use-tracker-clock.d.ts.map +1 -0
  50. package/dist/components/index.cjs +114 -102
  51. package/dist/components/index.cjs.map +1 -1
  52. package/dist/components/index.js +19 -7
  53. package/dist/components/index.js.map +1 -1
  54. package/dist/components/navigation/app-header.d.ts +2 -0
  55. package/dist/components/navigation/app-header.d.ts.map +1 -1
  56. package/dist/components/navigation/index.cjs +4 -4
  57. package/dist/components/navigation/index.js +3 -3
  58. package/dist/components/onboarding-guides/index.cjs +23 -23
  59. package/dist/components/onboarding-guides/index.js +3 -3
  60. package/dist/components/related-content/index.cjs +5 -5
  61. package/dist/components/related-content/index.js +4 -4
  62. package/dist/components/tickets/index.cjs +72 -72
  63. package/dist/components/tickets/index.js +5 -5
  64. package/dist/components/ui/autocomplete.d.ts.map +1 -1
  65. package/dist/components/ui/file-manager/index.cjs +20 -20
  66. package/dist/components/ui/file-manager/index.js +1 -1
  67. package/dist/components/ui/hidden-tags-popup.d.ts.map +1 -1
  68. package/dist/components/ui/index.cjs +4 -4
  69. package/dist/components/ui/index.js +3 -3
  70. package/dist/index.cjs +24 -4
  71. package/dist/index.cjs.map +1 -1
  72. package/dist/index.js +23 -3
  73. package/dist/utils/index.cjs +37 -4
  74. package/dist/utils/index.cjs.map +1 -1
  75. package/dist/utils/index.d.ts +1 -1
  76. package/dist/utils/index.d.ts.map +1 -1
  77. package/dist/utils/index.js +34 -5
  78. package/dist/utils/index.js.map +1 -1
  79. package/package.json +1 -1
  80. package/src/components/features/index.ts +1 -0
  81. package/src/components/features/time-tracker/index.ts +18 -0
  82. package/src/components/features/time-tracker/time-tracker-context.tsx +45 -0
  83. package/src/components/features/time-tracker/time-tracker-header-button.tsx +87 -0
  84. package/src/components/features/time-tracker/time-tracker-panel.tsx +270 -0
  85. package/src/components/features/time-tracker/types.ts +61 -0
  86. package/src/components/features/time-tracker/use-tracker-clock.ts +39 -0
  87. package/src/components/navigation/app-header.tsx +9 -0
  88. package/src/components/ui/autocomplete.tsx +2 -7
  89. package/src/components/ui/hidden-tags-popup.tsx +3 -1
  90. package/src/stories/TimeTracker.stories.tsx +355 -0
  91. package/src/utils/index.ts +13 -1
  92. package/dist/chunk-LALAS7OH.cjs.map +0 -1
  93. package/dist/chunk-LH7R56RR.cjs.map +0 -1
  94. package/dist/chunk-N5UOJC3V.cjs.map +0 -1
  95. package/dist/chunk-P6Y3S6QK.js.map +0 -1
  96. package/dist/chunk-ZS4XNIZK.js.map +0 -1
  97. /package/dist/{chunk-LDEXTWMK.js.map → chunk-5IJ46KAV.js.map} +0 -0
  98. /package/dist/{chunk-MHQPAA3W.js.map → chunk-5NL62LIF.js.map} +0 -0
  99. /package/dist/{chunk-KBKW4LVO.js.map → chunk-737ZLQ2T.js.map} +0 -0
  100. /package/dist/{chunk-DMAPXENZ.js.map → chunk-MDHNLZME.js.map} +0 -0
  101. /package/dist/{chunk-6A5GGUIA.js.map → chunk-MVUN35FH.js.map} +0 -0
  102. /package/dist/{chunk-UCX75VEM.js.map → chunk-R6SHY6ZQ.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.277",
3
+ "version": "0.0.278",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -76,5 +76,6 @@ export * from './ai-enrich'
76
76
  export * from './policy-configuration-panel'
77
77
  export * from './waitlist-form'
78
78
  export * from './notifications'
79
+ export * from './time-tracker'
79
80
  export * from './board'
80
81
  export * from './ticket-status-config-list'
@@ -0,0 +1,18 @@
1
+ export {
2
+ TimeTrackerProvider,
3
+ useTimeTracker,
4
+ useOptionalTimeTracker,
5
+ } from './time-tracker-context'
6
+ export type { TimeTrackerProviderProps } from './time-tracker-context'
7
+ export { TimeTrackerPanel } from './time-tracker-panel'
8
+ export type { TimeTrackerPanelProps } from './time-tracker-panel'
9
+ export { TimeTrackerHeaderButton } from './time-tracker-header-button'
10
+ export type { TimeTrackerHeaderButtonProps } from './time-tracker-header-button'
11
+ export { useTrackerClock } from './use-tracker-clock'
12
+ export type { UseTrackerClockArgs } from './use-tracker-clock'
13
+ export type {
14
+ TimeTrackerStatus,
15
+ TimeTrackerTicketOption,
16
+ TimeTrackerEntry,
17
+ TimeTrackerData,
18
+ } from './types'
@@ -0,0 +1,45 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import type { TimeTrackerData } from './types'
5
+
6
+ interface TimeTrackerContextValue extends TimeTrackerData {
7
+ isOpen: boolean
8
+ open: () => void
9
+ close: () => void
10
+ toggle: () => void
11
+ }
12
+
13
+ const TimeTrackerContext = React.createContext<TimeTrackerContextValue | null>(null)
14
+
15
+ export interface TimeTrackerProviderProps extends TimeTrackerData {
16
+ children: React.ReactNode
17
+ defaultOpen?: boolean
18
+ }
19
+
20
+ export function TimeTrackerProvider({ children, defaultOpen = false, ...data }: TimeTrackerProviderProps) {
21
+ const [isOpen, setIsOpen] = React.useState(defaultOpen)
22
+
23
+ const open = React.useCallback(() => setIsOpen(true), [])
24
+ const close = React.useCallback(() => setIsOpen(false), [])
25
+ const toggle = React.useCallback(() => setIsOpen((v) => !v), [])
26
+
27
+ // `data` is a fresh object each render, so the value identity tracks the host's
28
+ // render — the per-second timer tick lives in `useTrackerClock` (local state),
29
+ // not here, so consumers don't re-render on every tick.
30
+ const value: TimeTrackerContextValue = { ...data, isOpen, open, close, toggle }
31
+
32
+ return <TimeTrackerContext.Provider value={value}>{children}</TimeTrackerContext.Provider>
33
+ }
34
+
35
+ export function useTimeTracker(): TimeTrackerContextValue {
36
+ const ctx = React.useContext(TimeTrackerContext)
37
+ if (!ctx) {
38
+ throw new Error('useTimeTracker must be used inside <TimeTrackerProvider>')
39
+ }
40
+ return ctx
41
+ }
42
+
43
+ export function useOptionalTimeTracker(): TimeTrackerContextValue | null {
44
+ return React.useContext(TimeTrackerContext)
45
+ }
@@ -0,0 +1,87 @@
1
+ 'use client'
2
+
3
+ import * as PopoverPrimitive from '@radix-ui/react-popover'
4
+ import { cn } from '../../../utils/cn'
5
+ import { ClockHistoryIcon } from '../../icons-v2-generated/date-and-time/clock-history-icon'
6
+ import { HeaderButton } from '../../navigation/header-button'
7
+ import { TimeTrackerPanel } from './time-tracker-panel'
8
+ import { useOptionalTimeTracker } from './time-tracker-context'
9
+ import { useTrackerClock } from './use-tracker-clock'
10
+
11
+ export interface TimeTrackerHeaderButtonProps {
12
+ /** Dimming/disabled passthrough from the header. */
13
+ className?: string
14
+ disabled?: boolean
15
+ }
16
+
17
+ /**
18
+ * Header affordance for the time tracker. Renders nothing unless wrapped in a
19
+ * `<TimeTrackerProvider>`. The trigger is the standard `HeaderButton`; when a
20
+ * session is active it also shows the live elapsed time. The popup is a Radix
21
+ * Popover anchored under the button (not a modal/drawer).
22
+ */
23
+ export function TimeTrackerHeaderButton({ className, disabled }: TimeTrackerHeaderButtonProps) {
24
+ const ctx = useOptionalTimeTracker()
25
+ const elapsedLabel = useTrackerClock({
26
+ status: ctx?.status ?? 'ready',
27
+ runningSince: ctx?.runningSince,
28
+ accumulatedMs: ctx?.accumulatedMs,
29
+ })
30
+
31
+ if (!ctx) return null
32
+
33
+ const { isOpen, open, close, status } = ctx
34
+ const isPaused = status === 'paused'
35
+ const isActive = status === 'tracking' || isPaused
36
+
37
+ return (
38
+ <PopoverPrimitive.Root open={isOpen} onOpenChange={(next) => (next ? open() : close())}>
39
+ <PopoverPrimitive.Trigger asChild>
40
+ <HeaderButton
41
+ aria-label="Time tracker"
42
+ isActive={isOpen}
43
+ disabled={disabled}
44
+ className={cn(
45
+ isActive && 'w-auto gap-[var(--spacing-system-xsf)] px-[var(--spacing-system-sf)] md:w-auto',
46
+ className,
47
+ )}
48
+ icon={
49
+ <>
50
+ <ClockHistoryIcon
51
+ className={cn(
52
+ 'h-4 w-4 md:h-6 md:w-6',
53
+ isPaused ? 'text-ods-text-secondary' : isActive && 'text-ods-accent',
54
+ )}
55
+ />
56
+ {isActive && (
57
+ <span
58
+ className={cn(
59
+ 'text-h5 tabular-nums md:text-h4',
60
+ isPaused ? 'text-ods-text-secondary' : 'text-ods-text-primary',
61
+ )}
62
+ >
63
+ {elapsedLabel}
64
+ </span>
65
+ )}
66
+ </>
67
+ }
68
+ />
69
+ </PopoverPrimitive.Trigger>
70
+ <PopoverPrimitive.Portal>
71
+ <PopoverPrimitive.Content
72
+ align="end"
73
+ sideOffset={0}
74
+ collisionPadding={8}
75
+ className={cn(
76
+ 'z-[1300] w-[460px] max-w-[calc(100vw-1rem)] outline-none',
77
+ 'data-[state=open]:animate-in data-[state=closed]:animate-out',
78
+ 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
79
+ 'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
80
+ )}
81
+ >
82
+ <TimeTrackerPanel {...ctx} onClose={close} />
83
+ </PopoverPrimitive.Content>
84
+ </PopoverPrimitive.Portal>
85
+ </PopoverPrimitive.Root>
86
+ )
87
+ }
@@ -0,0 +1,270 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { cn } from '../../../utils/cn'
5
+ import { Autocomplete, type AutocompleteOption } from '../../ui/autocomplete'
6
+ import { Button } from '../../ui/button/button'
7
+ import { SplitButton } from '../../ui/button/split-button'
8
+ import { Tag } from '../../ui/tag'
9
+ import { Textarea } from '../../ui/textarea'
10
+ import {
11
+ ArrowRightUpIcon,
12
+ CheckCircleIcon,
13
+ Chevron02RightIcon,
14
+ DotsLoaderIcon,
15
+ PauseIcon,
16
+ PlayIcon,
17
+ PlusCircleIcon,
18
+ XmarkIcon,
19
+ } from '../../icons-v2-generated'
20
+ import { useTrackerClock } from './use-tracker-clock'
21
+ import type { TimeTrackerData, TimeTrackerEntry, TimeTrackerStatus } from './types'
22
+
23
+ export interface TimeTrackerPanelProps extends TimeTrackerData {
24
+ onClose: () => void
25
+ className?: string
26
+ }
27
+
28
+ export function TimeTrackerPanel({
29
+ status,
30
+ runningSince,
31
+ accumulatedMs,
32
+ ticketOptions,
33
+ selectedTicketIds,
34
+ onSelectedTicketsChange,
35
+ onTicketSearch,
36
+ ticketsLoading,
37
+ notes,
38
+ onNotesChange,
39
+ lastEntries,
40
+ onStart,
41
+ onPause,
42
+ onResume,
43
+ onCancel,
44
+ onSubmit,
45
+ onManualEntry,
46
+ onOpenMyTime,
47
+ onOpenMyTimeMenu,
48
+ onEntryClick,
49
+ isStarting,
50
+ isSubmitting,
51
+ onClose,
52
+ className,
53
+ }: TimeTrackerPanelProps) {
54
+ const elapsedLabel = useTrackerClock({ status, runningSince, accumulatedMs })
55
+
56
+ const [showValidationError, setShowValidationError] = useState(false)
57
+
58
+ const isRunning = status === 'tracking'
59
+ const isActive = status !== 'ready'
60
+ const hasContent = selectedTicketIds.length > 0 || notes.trim() !== ''
61
+ const showFieldError = isActive && showValidationError && !hasContent
62
+
63
+ // Reset the validation flag once a session ends so the next one starts clean.
64
+ useEffect(() => {
65
+ if (status === 'ready') setShowValidationError(false)
66
+ }, [status])
67
+
68
+ const handleSubmit = () => {
69
+ if (!hasContent) {
70
+ setShowValidationError(true)
71
+ return
72
+ }
73
+ onSubmit()
74
+ }
75
+
76
+ const ticketAutocompleteOptions: AutocompleteOption[] = ticketOptions.map((t) => ({
77
+ label: t.label,
78
+ value: t.id,
79
+ }))
80
+
81
+ const visibleEntries = lastEntries.slice(0, 3)
82
+
83
+ return (
84
+ <div
85
+ className={cn(
86
+ 'relative flex max-h-[calc(100vh-6rem)] w-full flex-col gap-[var(--spacing-system-l)] overflow-y-auto rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-m)]',
87
+ className,
88
+ )}
89
+ >
90
+ <Button
91
+ variant="transparent"
92
+ size="icon-sm"
93
+ onClick={onClose}
94
+ aria-label="Close time tracker"
95
+ className="absolute right-[var(--spacing-system-sf)] top-[var(--spacing-system-sf)] text-ods-text-secondary hover:text-ods-text-primary [&_svg]:!size-6"
96
+ >
97
+ <XmarkIcon />
98
+ </Button>
99
+
100
+ {/* Status + timer + ticket assignment */}
101
+ <div className="flex flex-col gap-[var(--spacing-system-m)]">
102
+ <div className="flex items-center gap-[var(--spacing-system-s)]">
103
+ <StatusTag status={status} />
104
+ {isActive && (
105
+ <Button
106
+ variant="transparent"
107
+ onClick={onCancel}
108
+ className="h-auto p-0 text-h6 font-medium text-ods-text-secondary underline hover:bg-transparent hover:text-ods-text-primary"
109
+ >
110
+ Cancel Entry
111
+ </Button>
112
+ )}
113
+ </div>
114
+
115
+ <div className="flex items-stretch overflow-hidden rounded-md border border-ods-border">
116
+ <div className="flex flex-1 items-center border-r border-ods-border bg-ods-bg px-[var(--spacing-system-m)] py-[var(--spacing-system-s)]">
117
+ <span
118
+ className={cn(
119
+ 'text-h3 font-bold tabular-nums',
120
+ isActive ? 'text-ods-text-primary' : 'text-ods-text-secondary',
121
+ )}
122
+ >
123
+ {elapsedLabel}
124
+ </span>
125
+ </div>
126
+
127
+ <Button
128
+ size="icon"
129
+ variant={isActive ? 'transparent' : 'accent'}
130
+ onClick={status === 'tracking' ? onPause : status === 'paused' ? onResume : onStart}
131
+ disabled={isStarting}
132
+ aria-label={isRunning ? 'Pause tracking' : isActive ? 'Resume tracking' : 'Start tracking'}
133
+ className="h-auto w-auto rounded-none [&_svg]:!size-6"
134
+ >
135
+ {isRunning ? <PauseIcon /> : <PlayIcon />}
136
+ </Button>
137
+
138
+ {isActive && (
139
+ <Button
140
+ size="icon"
141
+ variant="accent"
142
+ onClick={handleSubmit}
143
+ disabled={isSubmitting}
144
+ aria-label="Finish and save tracking"
145
+ className="h-auto w-auto rounded-none border-l border-ods-border [&_svg]:!size-6"
146
+ >
147
+ <CheckCircleIcon />
148
+ </Button>
149
+ )}
150
+ </div>
151
+
152
+ <Autocomplete
153
+ multiple
154
+ value={selectedTicketIds}
155
+ onChange={onSelectedTicketsChange}
156
+ options={ticketAutocompleteOptions}
157
+ placeholder="Assign Ticket"
158
+ loading={ticketsLoading}
159
+ invalid={showFieldError}
160
+ error={showFieldError ? 'Assign a ticket or add notes to finish' : undefined}
161
+ disableClientFilter={!!onTicketSearch}
162
+ onInputChange={(value, reason) => {
163
+ if (reason === 'input') onTicketSearch?.(value)
164
+ }}
165
+ />
166
+ </div>
167
+
168
+ <Textarea
169
+ value={notes}
170
+ onChange={(e) => onNotesChange(e.target.value)}
171
+ invalid={showFieldError}
172
+ placeholder="Additional Notes (optional if ticket selected)"
173
+ className="max-h-[280px]"
174
+ />
175
+
176
+ {/* Last entries + footer */}
177
+ <div className="flex flex-col gap-[var(--spacing-system-m)]">
178
+ <div className="flex flex-col gap-[var(--spacing-system-xs)]">
179
+ <p className="font-mono text-h6 uppercase tracking-wide text-ods-text-secondary">Last Entries</p>
180
+ <div className="overflow-hidden rounded-md border border-ods-border bg-ods-card">
181
+ {visibleEntries.length === 0 ? (
182
+ <div className="flex items-center justify-center px-[var(--spacing-system-m)] py-[var(--spacing-system-l)]">
183
+ <p className="text-h6 text-ods-text-secondary">No tracked sessions yet</p>
184
+ </div>
185
+ ) : (
186
+ visibleEntries.map((entry) => (
187
+ <LastEntryRow key={entry.id} entry={entry} onClick={onEntryClick} />
188
+ ))
189
+ )}
190
+ </div>
191
+ </div>
192
+
193
+ <div className="flex w-full flex-wrap gap-[var(--spacing-system-m)]">
194
+ <Button
195
+ variant="outline"
196
+ className="min-w-0 flex-1"
197
+ onClick={onManualEntry}
198
+ disabled={!onManualEntry}
199
+ leftIcon={<PlusCircleIcon className="text-ods-text-secondary" />}
200
+ >
201
+ Manual Entry
202
+ </Button>
203
+ <SplitButton
204
+ variant="outline"
205
+ onClick={onOpenMyTime}
206
+ disabled={!onOpenMyTime && !onOpenMyTimeMenu}
207
+ mainDisabled={!onOpenMyTime}
208
+ groupAriaLabel="Open my time"
209
+ iconAction={{
210
+ icon: <ArrowRightUpIcon size={24} />,
211
+ 'aria-label': 'Open my time in a new view',
212
+ onClick: onOpenMyTimeMenu,
213
+ disabled: !onOpenMyTimeMenu,
214
+ }}
215
+ >
216
+ Open My Time
217
+ </SplitButton>
218
+ </div>
219
+ </div>
220
+ </div>
221
+ )
222
+ }
223
+
224
+ const STATUS_TAG_SKIN = 'gap-[var(--spacing-system-xs)] font-mono font-medium uppercase tracking-tight'
225
+
226
+ function StatusTag({ status }: { status: TimeTrackerStatus }) {
227
+ if (status === 'tracking') {
228
+ return (
229
+ <Tag variant="success" className={STATUS_TAG_SKIN} icon={<DotsLoaderIcon className="h-4 w-4" />} label="Tracking" />
230
+ )
231
+ }
232
+ if (status === 'paused') {
233
+ return <Tag variant="warning" className={STATUS_TAG_SKIN} icon={<PauseIcon className="h-4 w-4" />} label="Paused" />
234
+ }
235
+ return <Tag variant="grey" className={STATUS_TAG_SKIN} label="Ready to Track" />
236
+ }
237
+
238
+ interface LastEntryRowProps {
239
+ entry: TimeTrackerEntry
240
+ onClick?: (entry: TimeTrackerEntry) => void
241
+ }
242
+
243
+ function LastEntryRow({ entry, onClick }: LastEntryRowProps) {
244
+ return (
245
+ <div className="flex items-center gap-[var(--spacing-system-m)] border-b border-ods-border bg-ods-bg px-[var(--spacing-system-m)] py-[var(--spacing-system-sf)] last:border-b-0">
246
+ <div className="flex w-[88px] shrink-0 flex-col justify-center">
247
+ <span className="truncate text-h4 font-medium text-ods-text-primary">{entry.durationLabel}</span>
248
+ <span className="truncate text-h6 text-ods-text-secondary">{entry.dateLabel}</span>
249
+ </div>
250
+
251
+ <div className="flex min-w-0 flex-1 flex-col justify-center">
252
+ <span className="truncate text-h4 font-medium text-ods-text-primary">{entry.title}</span>
253
+ {entry.description && (
254
+ <span className="truncate text-h6 text-ods-text-secondary">{entry.description}</span>
255
+ )}
256
+ </div>
257
+
258
+ <Button
259
+ variant="outline"
260
+ size="icon"
261
+ onClick={() => onClick?.(entry)}
262
+ disabled={!onClick}
263
+ aria-label={`Open entry: ${entry.title}`}
264
+ className="h-12 w-12 shrink-0 [&_svg]:!size-6"
265
+ >
266
+ <Chevron02RightIcon />
267
+ </Button>
268
+ </div>
269
+ )
270
+ }
@@ -0,0 +1,61 @@
1
+ export type TimeTrackerStatus = 'ready' | 'tracking' | 'paused'
2
+
3
+ export interface TimeTrackerTicketOption {
4
+ id: string
5
+ label: string
6
+ }
7
+
8
+ export interface TimeTrackerEntry {
9
+ id: string
10
+ /** Pre-formatted duration label, e.g. "00:08:17" (host formats it). */
11
+ durationLabel: string
12
+ /** Pre-formatted date label, e.g. "07/12/25". */
13
+ dateLabel: string
14
+ title: string
15
+ description?: string
16
+ }
17
+
18
+ /**
19
+ * Everything the host feeds into the purely-UI time tracker.
20
+ * The UI never performs API calls or owns business state — it renders `status` and
21
+ * the live timer (derived from `runningSince` + `accumulatedMs`) and emits callbacks.
22
+ */
23
+ export interface TimeTrackerData {
24
+ status: TimeTrackerStatus
25
+ /**
26
+ * Epoch ms when the current running segment started. Only meaningful while
27
+ * `status === 'tracking'`. The UI ticks a local interval to display elapsed time
28
+ * from this value — the backend remains the source of truth.
29
+ */
30
+ runningSince?: number | null
31
+ /** Elapsed ms accrued before the current running segment (paused/resumed sessions). */
32
+ accumulatedMs?: number
33
+
34
+ ticketOptions: TimeTrackerTicketOption[]
35
+ selectedTicketIds: string[]
36
+ onSelectedTicketsChange: (ids: string[]) => void
37
+ /** Called as the user types in the ticket field; host performs the search. */
38
+ onTicketSearch?: (query: string) => void
39
+ ticketsLoading?: boolean
40
+
41
+ notes: string
42
+ onNotesChange: (value: string) => void
43
+
44
+ /** Previous tracked sessions; the UI renders at most three. */
45
+ lastEntries: TimeTrackerEntry[]
46
+
47
+ onStart: () => void
48
+ onPause: () => void
49
+ onResume: () => void
50
+ /** Discard the in-progress session entirely (reset to idle). Shown while tracking/paused. */
51
+ onCancel: () => void
52
+ /** Finish the session. The UI only enables this once at least one field is filled. */
53
+ onSubmit: () => void
54
+ onManualEntry?: () => void
55
+ onOpenMyTime?: () => void
56
+ onOpenMyTimeMenu?: () => void
57
+ onEntryClick?: (entry: TimeTrackerEntry) => void
58
+
59
+ isStarting?: boolean
60
+ isSubmitting?: boolean
61
+ }
@@ -0,0 +1,39 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import type { TimeTrackerStatus } from './types'
5
+
6
+ export interface UseTrackerClockArgs {
7
+ status: TimeTrackerStatus
8
+ /** Epoch ms the current running segment started (only used while tracking). */
9
+ runningSince?: number | null
10
+ /** Elapsed ms accrued before the current running segment. */
11
+ accumulatedMs?: number
12
+ }
13
+
14
+ function formatElapsed(totalMs: number): string {
15
+ const totalSeconds = Math.max(0, Math.floor(totalMs / 1000))
16
+ const hours = Math.floor(totalSeconds / 3600)
17
+ const minutes = Math.floor((totalSeconds % 3600) / 60)
18
+ const seconds = totalSeconds % 60
19
+ const pad = (n: number) => String(n).padStart(2, '0')
20
+ return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`
21
+ }
22
+
23
+ /**
24
+ * Display-only timer. Ticks once per second while `status === 'tracking'`; otherwise
25
+ * renders the static `accumulatedMs`. Returns a formatted `HH:MM:SS` string.
26
+ */
27
+ export function useTrackerClock({ status, runningSince, accumulatedMs = 0 }: UseTrackerClockArgs): string {
28
+ const [now, setNow] = useState(() => Date.now())
29
+
30
+ useEffect(() => {
31
+ if (status !== 'tracking') return
32
+ setNow(Date.now())
33
+ const id = setInterval(() => setNow(Date.now()), 1000)
34
+ return () => clearInterval(id)
35
+ }, [status, runningSince])
36
+
37
+ const liveMs = status === 'tracking' && runningSince ? now - runningSince : 0
38
+ return formatElapsed(accumulatedMs + liveMs)
39
+ }
@@ -4,6 +4,7 @@ import React from 'react'
4
4
  import { useMdUp } from '../../hooks/ui/use-media-query'
5
5
  import { cn } from '../../utils/cn'
6
6
  import { useOptionalNotifications } from '../features/notifications/notifications-context'
7
+ import { TimeTrackerHeaderButton } from '../features/time-tracker'
7
8
  import { LogOutIcon, OpenFrameLogo, OpenFrameText, UserIcon } from '../icons'
8
9
  import { BellIcon } from '../icons-v2-generated/interface/bell-icon'
9
10
  import { Menu01Icon, SearchIcon, XmarkIcon } from '../icons-v2-generated'
@@ -22,6 +23,8 @@ export interface AppHeaderProps {
22
23
  onOrgChange?: (id: string) => void
23
24
  showNotifications?: boolean
24
25
  unreadCount?: number
26
+ /** Render the time-tracker button + popup. Requires wrapping the app in `<TimeTrackerProvider>`. */
27
+ showTimeTracker?: boolean
25
28
  /** Render the "Mingo AI" launcher button (drawer-style trigger for an
26
29
  * in-layout `AppLayoutDrawer` hosting the chat panel). Defaults to off. */
27
30
  showMingoAI?: boolean
@@ -57,6 +60,7 @@ export const AppHeader = React.memo(function AppHeader({
57
60
  onOrgChange,
58
61
  showNotifications,
59
62
  unreadCount = 0,
63
+ showTimeTracker = false,
60
64
  showMingoAI = false,
61
65
  onMingoAI,
62
66
  isMingoAIActive = false,
@@ -129,6 +133,11 @@ export const AppHeader = React.memo(function AppHeader({
129
133
  />
130
134
  )}
131
135
 
136
+ {/* Time tracker button */}
137
+ {showTimeTracker && (
138
+ <TimeTrackerHeaderButton disabled={disabled} className={dimmedClass} />
139
+ )}
140
+
132
141
  {/* Notifications button */}
133
142
  {showNotifications && (
134
143
  <NotificationsHeaderButton
@@ -635,18 +635,13 @@ function AutocompleteInner<T = string>(
635
635
  <div className="relative" ref={containerRef}>
636
636
  {popover}
637
637
 
638
- {/* Hidden tags popup — outside overflow-hidden, positioned under badge */}
638
+ {/* Hidden tags popup — outside overflow-hidden; right-anchored to the field */}
639
639
  {multiple && showHiddenTags && hiddenTagsCount > 0 && (
640
640
  <HiddenTagsPopup
641
641
  ref={hiddenTagsPopupRef}
642
642
  items={hiddenTags}
643
643
  disabled={disabled}
644
- style={{
645
- left: autoLimitTags.badgeRef.current
646
- ? autoLimitTags.badgeRef.current.getBoundingClientRect().left -
647
- (containerRef.current?.getBoundingClientRect().left ?? 0)
648
- : 0,
649
- }}
644
+ className="left-auto right-0 max-w-full"
650
645
  onRemove={(value) => {
651
646
  const newValue = valueArray.filter(v => v !== value)
652
647
  ;(props as AutocompleteMultipleProps<T>).onChange(newValue)
@@ -26,6 +26,8 @@ export const HiddenTagsPopup = forwardRef(function HiddenTagsPopup(
26
26
  ref={ref}
27
27
  style={style}
28
28
  className={cn(
29
+ // Base positioning is neutral (left-anchored); consumers override via
30
+ // `style.left` (search-input, tag-search-input) or `className` (autocomplete).
29
31
  "absolute top-full left-0 mt-1 z-50 min-w-[200px]",
30
32
  "bg-ods-card border border-ods-border rounded-[6px] shadow-lg",
31
33
  "animate-in fade-in-0 zoom-in-95 duration-150",
@@ -40,7 +42,7 @@ export const HiddenTagsPopup = forwardRef(function HiddenTagsPopup(
40
42
  "border-b border-ods-border last:border-b-0",
41
43
  )}
42
44
  >
43
- <span className="text-h5 truncate uppercase text-ods-text-primary" title={typeof item.label === 'string' ? item.label : undefined}>
45
+ <span className="min-w-0 flex-1 text-h5 truncate uppercase text-ods-text-primary" title={typeof item.label === 'string' ? item.label : undefined}>
44
46
  {item.label}
45
47
  </span>
46
48
  {!disabled && onRemove && (