@open-mercato/ui 0.6.4-develop.4121.1.0d7f20d229 → 0.6.4-develop.4133.1.48fc6c8f7b

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 (29) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/backend/AppShell.js +2 -2
  3. package/dist/backend/AppShell.js.map +2 -2
  4. package/dist/backend/dashboard/DashboardScreen.js +3 -2
  5. package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
  6. package/dist/backend/dashboard/index.js +4 -1
  7. package/dist/backend/dashboard/index.js.map +2 -2
  8. package/dist/backend/dashboard/widgetData.js +59 -0
  9. package/dist/backend/dashboard/widgetData.js.map +7 -0
  10. package/dist/backend/dashboard/widgetDataBatcher.js +61 -0
  11. package/dist/backend/dashboard/widgetDataBatcher.js.map +7 -0
  12. package/dist/backend/progress/ProgressTopBar.js +6 -4
  13. package/dist/backend/progress/ProgressTopBar.js.map +2 -2
  14. package/dist/backend/progress/useAutoHideCompletedJobs.js +65 -0
  15. package/dist/backend/progress/useAutoHideCompletedJobs.js.map +7 -0
  16. package/dist/primitives/date-picker.js +1 -0
  17. package/dist/primitives/date-picker.js.map +2 -2
  18. package/package.json +3 -3
  19. package/src/backend/AppShell.tsx +7 -2
  20. package/src/backend/__tests__/useAutoHideCompletedJobs.test.tsx +179 -0
  21. package/src/backend/dashboard/DashboardScreen.tsx +3 -0
  22. package/src/backend/dashboard/__tests__/widgetDataBatcher.test.ts +97 -0
  23. package/src/backend/dashboard/index.ts +1 -0
  24. package/src/backend/dashboard/widgetData.tsx +84 -0
  25. package/src/backend/dashboard/widgetDataBatcher.ts +100 -0
  26. package/src/backend/progress/ProgressTopBar.tsx +12 -4
  27. package/src/backend/progress/useAutoHideCompletedJobs.ts +100 -0
  28. package/src/primitives/__tests__/date-picker.test.tsx +12 -0
  29. package/src/primitives/date-picker.tsx +1 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/primitives/date-picker.tsx"],
4
- "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport type { Locale } from 'date-fns/locale'\nimport { CalendarIcon } from 'lucide-react'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Popover, PopoverContent, PopoverTrigger } from './popover'\nimport { Calendar } from './calendar'\nimport { Button } from './button'\nimport { LinkButton } from './link-button'\nimport { TimeInput } from '../backend/inputs/TimeInput'\nimport { formatWithPublicDateFormat, resolvePublicDateFormat, resolvePublicDateTimeFormat } from './date-format'\n\nexport type DatePickerFooter = 'apply-cancel' | 'today-clear' | 'none'\n\nexport type DatePickerProps = {\n value?: Date | null\n onChange: (value: Date | null) => void\n placeholder?: string\n size?: 'sm' | 'default'\n disabled?: boolean\n readOnly?: boolean\n /**\n * Footer mode in the popover.\n * - `'apply-cancel'` (default, Figma-aligned): selecting a day stages a draft;\n * Apply commits and closes; Cancel reverts and closes.\n * - `'today-clear'`: legacy behavior \u2014 Today commits today, Clear sets null.\n * - `'none'`: no footer; selecting a day commits immediately and closes.\n */\n footer?: DatePickerFooter\n /**\n * Whether selecting a day commits the value and closes the popover.\n * - In `footer='apply-cancel'`: ignored (commit happens via Apply).\n * - In `footer='today-clear'`: default `true` for backward compatibility.\n * - In `footer='none'`: always `true` (popover always closes after selection).\n */\n closeOnSelect?: boolean\n /**\n * Whether to show the \"Today\" link in `footer='today-clear'` mode. Default `true`.\n * Only meaningful when `footer='today-clear'`.\n */\n showTodayButton?: boolean\n /**\n * Whether to show the \"Clear\" link in `footer='today-clear'` mode. Default `true`.\n * Only meaningful when `footer='today-clear'`.\n */\n showClearButton?: boolean\n withTime?: boolean\n minuteStep?: number\n align?: 'start' | 'center' | 'end'\n minDate?: Date\n maxDate?: Date\n locale?: Locale\n displayFormat?: string\n className?: string\n popoverClassName?: string\n id?: string\n name?: string\n required?: boolean\n 'aria-label'?: string\n 'aria-describedby'?: string\n}\n\nfunction extractTime(date: Date): string {\n return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`\n}\n\nfunction applyTimeToDate(base: Date, time: string): Date {\n const [hourStr = '0', minuteStr = '0'] = time.split(':')\n const hour = parseInt(hourStr, 10)\n const minute = parseInt(minuteStr, 10)\n const next = new Date(base)\n next.setHours(isNaN(hour) ? 0 : hour, isNaN(minute) ? 0 : minute, 0, 0)\n return next\n}\n\nfunction startOfDay(date: Date): Date {\n const next = new Date(date)\n next.setHours(0, 0, 0, 0)\n return next\n}\n\nexport function DatePicker({\n value,\n onChange,\n placeholder,\n size = 'default',\n disabled = false,\n readOnly = false,\n footer = 'apply-cancel',\n closeOnSelect,\n showTodayButton = true,\n showClearButton = true,\n withTime = false,\n minuteStep = 1,\n align = 'start',\n minDate,\n maxDate,\n locale,\n displayFormat,\n className,\n popoverClassName,\n id,\n name,\n required,\n 'aria-label': ariaLabel,\n 'aria-describedby': ariaDescribedBy,\n}: DatePickerProps) {\n const resolvedCloseOnSelect = closeOnSelect ?? footer === 'today-clear'\n const t = useT()\n const [open, setOpen] = React.useState(false)\n const [draft, setDraft] = React.useState<Date | null>(value ?? null)\n\n const isInteractive = !disabled && !readOnly\n const useDraft = footer === 'apply-cancel'\n\n React.useEffect(() => {\n if (open) setDraft(value ?? null)\n }, [open, value])\n\n const resolvedFormat = withTime\n ? resolvePublicDateTimeFormat(locale, displayFormat)\n : resolvePublicDateFormat(locale, displayFormat)\n const placeholderText = placeholder ?? (\n withTime\n ? t('ui.dateTimePicker.placeholder', 'Pick date and time')\n : t('ui.datePicker.placeholder', 'Pick a date')\n )\n\n const formattedValue = React.useMemo(() => {\n if (!value) return null\n return formatWithPublicDateFormat(value, resolvedFormat, locale)\n }, [value, resolvedFormat, locale])\n\n const handleDaySelect = React.useCallback(\n (day: Date | undefined) => {\n if (!day || !isInteractive) return\n const reference = withTime ? (useDraft ? draft : value) : null\n const base = withTime\n ? applyTimeToDate(day, reference ? extractTime(reference) : '00:00')\n : startOfDay(day)\n if (useDraft) {\n setDraft(base)\n } else {\n onChange(base)\n if (footer === 'none' || resolvedCloseOnSelect) setOpen(false)\n }\n },\n [isInteractive, withTime, useDraft, draft, value, footer, resolvedCloseOnSelect, onChange],\n )\n\n const handleTimeChange = React.useCallback(\n (time: string) => {\n const target = useDraft ? draft : value\n if (!target) return\n const next = applyTimeToDate(target, time)\n if (useDraft) setDraft(next)\n else onChange(next)\n },\n [useDraft, draft, value, onChange],\n )\n\n const handleApply = React.useCallback(() => {\n onChange(draft)\n setOpen(false)\n }, [draft, onChange])\n\n const handleCancel = React.useCallback(() => {\n setDraft(value ?? null)\n setOpen(false)\n }, [value])\n\n const handleToday = React.useCallback(() => {\n const today = withTime ? new Date() : startOfDay(new Date())\n onChange(today)\n setOpen(false)\n }, [withTime, onChange])\n\n const handleClear = React.useCallback(() => {\n onChange(null)\n setOpen(false)\n }, [onChange])\n\n const disabledMatcher = React.useMemo(() => {\n if (!minDate && !maxDate) return undefined\n const matchers: import('react-day-picker').Matcher[] = []\n if (minDate) matchers.push({ before: minDate })\n if (maxDate) matchers.push({ after: maxDate })\n return matchers\n }, [minDate, maxDate])\n\n const heightClass = size === 'sm' ? 'h-8 px-2.5 text-xs' : 'h-9 px-3 text-sm'\n const selectedDate = useDraft ? draft : value\n\n return (\n <Popover open={open} onOpenChange={isInteractive ? setOpen : undefined}>\n <PopoverTrigger asChild>\n <button\n type=\"button\"\n id={id}\n name={name}\n aria-label={ariaLabel}\n aria-describedby={ariaDescribedBy}\n aria-haspopup=\"dialog\"\n aria-required={required ? true : undefined}\n data-crud-focus-target=\"\"\n data-slot=\"date-picker-trigger\"\n disabled={disabled}\n className={cn(\n 'w-full inline-flex items-center gap-2 rounded-md border border-input bg-background shadow-xs transition-colors text-left',\n 'focus-visible:outline-none focus-visible:shadow-focus focus-visible:border-foreground',\n 'hover:bg-muted/40',\n 'disabled:bg-bg-disabled disabled:border-border-disabled disabled:shadow-none disabled:hover:bg-bg-disabled disabled:cursor-not-allowed',\n 'aria-invalid:border-destructive',\n heightClass,\n readOnly && 'cursor-default opacity-70',\n !formattedValue && 'text-muted-foreground',\n className,\n )}\n onClick={isInteractive ? undefined : (event) => event.preventDefault()}\n >\n <CalendarIcon className=\"h-4 w-4 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n <span className=\"flex-1 truncate\">\n {formattedValue ?? placeholderText}\n </span>\n </button>\n </PopoverTrigger>\n <PopoverContent align={align} className={cn('w-auto p-0', popoverClassName)}>\n <Calendar\n mode=\"single\"\n selected={selectedDate ?? undefined}\n onSelect={handleDaySelect}\n locale={locale}\n disabled={disabledMatcher}\n autoFocus\n />\n {withTime && (\n <div className=\"flex items-center gap-2 border-t px-3 py-2\">\n <span className=\"text-sm text-muted-foreground shrink-0\">\n {t('ui.datePicker.timeLabel', 'Time')}:\n </span>\n <TimeInput\n value={selectedDate ? extractTime(selectedDate) : undefined}\n onChange={handleTimeChange}\n minuteStep={minuteStep}\n disabled={!isInteractive || !selectedDate}\n />\n </div>\n )}\n {footer === 'apply-cancel' && (\n <div className=\"flex items-center justify-end gap-2 border-t px-3 py-2\">\n <Button type=\"button\" variant=\"outline\" size=\"sm\" onClick={handleCancel}>\n {t('ui.datePicker.cancelButton', 'Cancel')}\n </Button>\n <Button type=\"button\" size=\"sm\" onClick={handleApply}>\n {t('ui.datePicker.applyButton', 'Apply')}\n </Button>\n </div>\n )}\n {footer === 'today-clear' && (showTodayButton || showClearButton) && (\n <div className=\"flex items-center justify-between gap-2 border-t px-3 py-2\">\n {showTodayButton ? (\n <LinkButton type=\"button\" variant=\"primary\" onClick={handleToday}>\n {t('ui.datePicker.todayButton', 'Today')}\n </LinkButton>\n ) : null}\n {showClearButton ? (\n <LinkButton type=\"button\" variant=\"gray\" onClick={handleClear} className=\"ml-auto\">\n {t('ui.datePicker.clearButton', 'Clear')}\n </LinkButton>\n ) : null}\n </div>\n )}\n </PopoverContent>\n </Popover>\n )\n}\n"],
5
- "mappings": ";AAsMQ,SAwBE,KAxBF;AApMR,YAAY,WAAW;AAEvB,SAAS,oBAAoB;AAC7B,SAAS,UAAU;AACnB,SAAS,YAAY;AACrB,SAAS,SAAS,gBAAgB,sBAAsB;AACxD,SAAS,gBAAgB;AACzB,SAAS,cAAc;AACvB,SAAS,kBAAkB;AAC3B,SAAS,iBAAiB;AAC1B,SAAS,4BAA4B,yBAAyB,mCAAmC;AAoDjG,SAAS,YAAY,MAAoB;AACvC,SAAO,GAAG,OAAO,KAAK,SAAS,CAAC,EAAE,SAAS,GAAG,GAAG,CAAC,IAAI,OAAO,KAAK,WAAW,CAAC,EAAE,SAAS,GAAG,GAAG,CAAC;AAClG;AAEA,SAAS,gBAAgB,MAAY,MAAoB;AACvD,QAAM,CAAC,UAAU,KAAK,YAAY,GAAG,IAAI,KAAK,MAAM,GAAG;AACvD,QAAM,OAAO,SAAS,SAAS,EAAE;AACjC,QAAM,SAAS,SAAS,WAAW,EAAE;AACrC,QAAM,OAAO,IAAI,KAAK,IAAI;AAC1B,OAAK,SAAS,MAAM,IAAI,IAAI,IAAI,MAAM,MAAM,MAAM,IAAI,IAAI,QAAQ,GAAG,CAAC;AACtE,SAAO;AACT;AAEA,SAAS,WAAW,MAAkB;AACpC,QAAM,OAAO,IAAI,KAAK,IAAI;AAC1B,OAAK,SAAS,GAAG,GAAG,GAAG,CAAC;AACxB,SAAO;AACT;AAEO,SAAS,WAAW;AAAA,EACzB;AAAA,EACA;AAAA,EACA;AAAA,EACA,OAAO;AAAA,EACP,WAAW;AAAA,EACX,WAAW;AAAA,EACX,SAAS;AAAA,EACT;AAAA,EACA,kBAAkB;AAAA,EAClB,kBAAkB;AAAA,EAClB,WAAW;AAAA,EACX,aAAa;AAAA,EACb,QAAQ;AAAA,EACR;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,cAAc;AAAA,EACd,oBAAoB;AACtB,GAAoB;AAClB,QAAM,wBAAwB,iBAAiB,WAAW;AAC1D,QAAM,IAAI,KAAK;AACf,QAAM,CAAC,MAAM,OAAO,IAAI,MAAM,SAAS,KAAK;AAC5C,QAAM,CAAC,OAAO,QAAQ,IAAI,MAAM,SAAsB,SAAS,IAAI;AAEnE,QAAM,gBAAgB,CAAC,YAAY,CAAC;AACpC,QAAM,WAAW,WAAW;AAE5B,QAAM,UAAU,MAAM;AACpB,QAAI,KAAM,UAAS,SAAS,IAAI;AAAA,EAClC,GAAG,CAAC,MAAM,KAAK,CAAC;AAEhB,QAAM,iBAAiB,WACnB,4BAA4B,QAAQ,aAAa,IACjD,wBAAwB,QAAQ,aAAa;AACjD,QAAM,kBAAkB,gBACtB,WACI,EAAE,iCAAiC,oBAAoB,IACvD,EAAE,6BAA6B,aAAa;AAGlD,QAAM,iBAAiB,MAAM,QAAQ,MAAM;AACzC,QAAI,CAAC,MAAO,QAAO;AACnB,WAAO,2BAA2B,OAAO,gBAAgB,MAAM;AAAA,EACjE,GAAG,CAAC,OAAO,gBAAgB,MAAM,CAAC;AAElC,QAAM,kBAAkB,MAAM;AAAA,IAC5B,CAAC,QAA0B;AACzB,UAAI,CAAC,OAAO,CAAC,cAAe;AAC5B,YAAM,YAAY,WAAY,WAAW,QAAQ,QAAS;AAC1D,YAAM,OAAO,WACT,gBAAgB,KAAK,YAAY,YAAY,SAAS,IAAI,OAAO,IACjE,WAAW,GAAG;AAClB,UAAI,UAAU;AACZ,iBAAS,IAAI;AAAA,MACf,OAAO;AACL,iBAAS,IAAI;AACb,YAAI,WAAW,UAAU,sBAAuB,SAAQ,KAAK;AAAA,MAC/D;AAAA,IACF;AAAA,IACA,CAAC,eAAe,UAAU,UAAU,OAAO,OAAO,QAAQ,uBAAuB,QAAQ;AAAA,EAC3F;AAEA,QAAM,mBAAmB,MAAM;AAAA,IAC7B,CAAC,SAAiB;AAChB,YAAM,SAAS,WAAW,QAAQ;AAClC,UAAI,CAAC,OAAQ;AACb,YAAM,OAAO,gBAAgB,QAAQ,IAAI;AACzC,UAAI,SAAU,UAAS,IAAI;AAAA,UACtB,UAAS,IAAI;AAAA,IACpB;AAAA,IACA,CAAC,UAAU,OAAO,OAAO,QAAQ;AAAA,EACnC;AAEA,QAAM,cAAc,MAAM,YAAY,MAAM;AAC1C,aAAS,KAAK;AACd,YAAQ,KAAK;AAAA,EACf,GAAG,CAAC,OAAO,QAAQ,CAAC;AAEpB,QAAM,eAAe,MAAM,YAAY,MAAM;AAC3C,aAAS,SAAS,IAAI;AACtB,YAAQ,KAAK;AAAA,EACf,GAAG,CAAC,KAAK,CAAC;AAEV,QAAM,cAAc,MAAM,YAAY,MAAM;AAC1C,UAAM,QAAQ,WAAW,oBAAI,KAAK,IAAI,WAAW,oBAAI,KAAK,CAAC;AAC3D,aAAS,KAAK;AACd,YAAQ,KAAK;AAAA,EACf,GAAG,CAAC,UAAU,QAAQ,CAAC;AAEvB,QAAM,cAAc,MAAM,YAAY,MAAM;AAC1C,aAAS,IAAI;AACb,YAAQ,KAAK;AAAA,EACf,GAAG,CAAC,QAAQ,CAAC;AAEb,QAAM,kBAAkB,MAAM,QAAQ,MAAM;AAC1C,QAAI,CAAC,WAAW,CAAC,QAAS,QAAO;AACjC,UAAM,WAAiD,CAAC;AACxD,QAAI,QAAS,UAAS,KAAK,EAAE,QAAQ,QAAQ,CAAC;AAC9C,QAAI,QAAS,UAAS,KAAK,EAAE,OAAO,QAAQ,CAAC;AAC7C,WAAO;AAAA,EACT,GAAG,CAAC,SAAS,OAAO,CAAC;AAErB,QAAM,cAAc,SAAS,OAAO,uBAAuB;AAC3D,QAAM,eAAe,WAAW,QAAQ;AAExC,SACE,qBAAC,WAAQ,MAAY,cAAc,gBAAgB,UAAU,QAC3D;AAAA,wBAAC,kBAAe,SAAO,MACrB;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL;AAAA,QACA;AAAA,QACA,cAAY;AAAA,QACZ,oBAAkB;AAAA,QAClB,iBAAc;AAAA,QACd,iBAAe,WAAW,OAAO;AAAA,QACjC,0BAAuB;AAAA,QACvB,aAAU;AAAA,QACV;AAAA,QACA,WAAW;AAAA,UACT;AAAA,UACA;AAAA,UACA;AAAA,UACA;AAAA,UACA;AAAA,UACA;AAAA,UACA,YAAY;AAAA,UACZ,CAAC,kBAAkB;AAAA,UACnB;AAAA,QACF;AAAA,QACA,SAAS,gBAAgB,SAAY,CAAC,UAAU,MAAM,eAAe;AAAA,QAErE;AAAA,8BAAC,gBAAa,WAAU,0CAAyC,eAAY,QAAO;AAAA,UACpF,oBAAC,UAAK,WAAU,mBACb,4BAAkB,iBACrB;AAAA;AAAA;AAAA,IACF,GACF;AAAA,IACA,qBAAC,kBAAe,OAAc,WAAW,GAAG,cAAc,gBAAgB,GACxE;AAAA;AAAA,QAAC;AAAA;AAAA,UACC,MAAK;AAAA,UACL,UAAU,gBAAgB;AAAA,UAC1B,UAAU;AAAA,UACV;AAAA,UACA,UAAU;AAAA,UACV,WAAS;AAAA;AAAA,MACX;AAAA,MACC,YACC,qBAAC,SAAI,WAAU,8CACb;AAAA,6BAAC,UAAK,WAAU,0CACb;AAAA,YAAE,2BAA2B,MAAM;AAAA,UAAE;AAAA,WACxC;AAAA,QACA;AAAA,UAAC;AAAA;AAAA,YACC,OAAO,eAAe,YAAY,YAAY,IAAI;AAAA,YAClD,UAAU;AAAA,YACV;AAAA,YACA,UAAU,CAAC,iBAAiB,CAAC;AAAA;AAAA,QAC/B;AAAA,SACF;AAAA,MAED,WAAW,kBACV,qBAAC,SAAI,WAAU,0DACb;AAAA,4BAAC,UAAO,MAAK,UAAS,SAAQ,WAAU,MAAK,MAAK,SAAS,cACxD,YAAE,8BAA8B,QAAQ,GAC3C;AAAA,QACA,oBAAC,UAAO,MAAK,UAAS,MAAK,MAAK,SAAS,aACtC,YAAE,6BAA6B,OAAO,GACzC;AAAA,SACF;AAAA,MAED,WAAW,kBAAkB,mBAAmB,oBAC/C,qBAAC,SAAI,WAAU,8DACZ;AAAA,0BACC,oBAAC,cAAW,MAAK,UAAS,SAAQ,WAAU,SAAS,aAClD,YAAE,6BAA6B,OAAO,GACzC,IACE;AAAA,QACH,kBACC,oBAAC,cAAW,MAAK,UAAS,SAAQ,QAAO,SAAS,aAAa,WAAU,WACtE,YAAE,6BAA6B,OAAO,GACzC,IACE;AAAA,SACN;AAAA,OAEJ;AAAA,KACF;AAEJ;",
4
+ "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport type { Locale } from 'date-fns/locale'\nimport { CalendarIcon } from 'lucide-react'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Popover, PopoverContent, PopoverTrigger } from './popover'\nimport { Calendar } from './calendar'\nimport { Button } from './button'\nimport { LinkButton } from './link-button'\nimport { TimeInput } from '../backend/inputs/TimeInput'\nimport { formatWithPublicDateFormat, resolvePublicDateFormat, resolvePublicDateTimeFormat } from './date-format'\n\nexport type DatePickerFooter = 'apply-cancel' | 'today-clear' | 'none'\n\nexport type DatePickerProps = {\n value?: Date | null\n onChange: (value: Date | null) => void\n placeholder?: string\n size?: 'sm' | 'default'\n disabled?: boolean\n readOnly?: boolean\n /**\n * Footer mode in the popover.\n * - `'apply-cancel'` (default, Figma-aligned): selecting a day stages a draft;\n * Apply commits and closes; Cancel reverts and closes.\n * - `'today-clear'`: legacy behavior \u2014 Today commits today, Clear sets null.\n * - `'none'`: no footer; selecting a day commits immediately and closes.\n */\n footer?: DatePickerFooter\n /**\n * Whether selecting a day commits the value and closes the popover.\n * - In `footer='apply-cancel'`: ignored (commit happens via Apply).\n * - In `footer='today-clear'`: default `true` for backward compatibility.\n * - In `footer='none'`: always `true` (popover always closes after selection).\n */\n closeOnSelect?: boolean\n /**\n * Whether to show the \"Today\" link in `footer='today-clear'` mode. Default `true`.\n * Only meaningful when `footer='today-clear'`.\n */\n showTodayButton?: boolean\n /**\n * Whether to show the \"Clear\" link in `footer='today-clear'` mode. Default `true`.\n * Only meaningful when `footer='today-clear'`.\n */\n showClearButton?: boolean\n withTime?: boolean\n minuteStep?: number\n align?: 'start' | 'center' | 'end'\n minDate?: Date\n maxDate?: Date\n locale?: Locale\n displayFormat?: string\n className?: string\n popoverClassName?: string\n id?: string\n name?: string\n required?: boolean\n 'aria-label'?: string\n 'aria-describedby'?: string\n}\n\nfunction extractTime(date: Date): string {\n return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`\n}\n\nfunction applyTimeToDate(base: Date, time: string): Date {\n const [hourStr = '0', minuteStr = '0'] = time.split(':')\n const hour = parseInt(hourStr, 10)\n const minute = parseInt(minuteStr, 10)\n const next = new Date(base)\n next.setHours(isNaN(hour) ? 0 : hour, isNaN(minute) ? 0 : minute, 0, 0)\n return next\n}\n\nfunction startOfDay(date: Date): Date {\n const next = new Date(date)\n next.setHours(0, 0, 0, 0)\n return next\n}\n\nexport function DatePicker({\n value,\n onChange,\n placeholder,\n size = 'default',\n disabled = false,\n readOnly = false,\n footer = 'apply-cancel',\n closeOnSelect,\n showTodayButton = true,\n showClearButton = true,\n withTime = false,\n minuteStep = 1,\n align = 'start',\n minDate,\n maxDate,\n locale,\n displayFormat,\n className,\n popoverClassName,\n id,\n name,\n required,\n 'aria-label': ariaLabel,\n 'aria-describedby': ariaDescribedBy,\n}: DatePickerProps) {\n const resolvedCloseOnSelect = closeOnSelect ?? footer === 'today-clear'\n const t = useT()\n const [open, setOpen] = React.useState(false)\n const [draft, setDraft] = React.useState<Date | null>(value ?? null)\n\n const isInteractive = !disabled && !readOnly\n const useDraft = footer === 'apply-cancel'\n\n React.useEffect(() => {\n if (open) setDraft(value ?? null)\n }, [open, value])\n\n const resolvedFormat = withTime\n ? resolvePublicDateTimeFormat(locale, displayFormat)\n : resolvePublicDateFormat(locale, displayFormat)\n const placeholderText = placeholder ?? (\n withTime\n ? t('ui.dateTimePicker.placeholder', 'Pick date and time')\n : t('ui.datePicker.placeholder', 'Pick a date')\n )\n\n const formattedValue = React.useMemo(() => {\n if (!value) return null\n return formatWithPublicDateFormat(value, resolvedFormat, locale)\n }, [value, resolvedFormat, locale])\n\n const handleDaySelect = React.useCallback(\n (day: Date | undefined) => {\n if (!day || !isInteractive) return\n const reference = withTime ? (useDraft ? draft : value) : null\n const base = withTime\n ? applyTimeToDate(day, reference ? extractTime(reference) : '00:00')\n : startOfDay(day)\n if (useDraft) {\n setDraft(base)\n } else {\n onChange(base)\n if (footer === 'none' || resolvedCloseOnSelect) setOpen(false)\n }\n },\n [isInteractive, withTime, useDraft, draft, value, footer, resolvedCloseOnSelect, onChange],\n )\n\n const handleTimeChange = React.useCallback(\n (time: string) => {\n const target = useDraft ? draft : value\n if (!target) return\n const next = applyTimeToDate(target, time)\n if (useDraft) setDraft(next)\n else onChange(next)\n },\n [useDraft, draft, value, onChange],\n )\n\n const handleApply = React.useCallback(() => {\n onChange(draft)\n setOpen(false)\n }, [draft, onChange])\n\n const handleCancel = React.useCallback(() => {\n setDraft(value ?? null)\n setOpen(false)\n }, [value])\n\n const handleToday = React.useCallback(() => {\n const today = withTime ? new Date() : startOfDay(new Date())\n onChange(today)\n setOpen(false)\n }, [withTime, onChange])\n\n const handleClear = React.useCallback(() => {\n onChange(null)\n setOpen(false)\n }, [onChange])\n\n const disabledMatcher = React.useMemo(() => {\n if (!minDate && !maxDate) return undefined\n const matchers: import('react-day-picker').Matcher[] = []\n if (minDate) matchers.push({ before: minDate })\n if (maxDate) matchers.push({ after: maxDate })\n return matchers\n }, [minDate, maxDate])\n\n const heightClass = size === 'sm' ? 'h-8 px-2.5 text-xs' : 'h-9 px-3 text-sm'\n const selectedDate = useDraft ? draft : value\n\n return (\n <Popover open={open} onOpenChange={isInteractive ? setOpen : undefined}>\n <PopoverTrigger asChild>\n <button\n type=\"button\"\n id={id}\n name={name}\n aria-label={ariaLabel}\n aria-describedby={ariaDescribedBy}\n aria-haspopup=\"dialog\"\n aria-required={required ? true : undefined}\n data-crud-focus-target=\"\"\n data-slot=\"date-picker-trigger\"\n disabled={disabled}\n className={cn(\n 'w-full inline-flex items-center gap-2 rounded-md border border-input bg-background shadow-xs transition-colors text-left',\n 'focus-visible:outline-none focus-visible:shadow-focus focus-visible:border-foreground',\n 'hover:bg-muted/40',\n 'disabled:bg-bg-disabled disabled:border-border-disabled disabled:shadow-none disabled:hover:bg-bg-disabled disabled:cursor-not-allowed',\n 'aria-invalid:border-destructive',\n heightClass,\n readOnly && 'cursor-default opacity-70',\n !formattedValue && 'text-muted-foreground',\n className,\n )}\n onClick={isInteractive ? undefined : (event) => event.preventDefault()}\n >\n <CalendarIcon className=\"h-4 w-4 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n <span className=\"flex-1 truncate\">\n {formattedValue ?? placeholderText}\n </span>\n </button>\n </PopoverTrigger>\n <PopoverContent align={align} className={cn('w-auto p-0', popoverClassName)}>\n <Calendar\n mode=\"single\"\n selected={selectedDate ?? undefined}\n defaultMonth={selectedDate ?? undefined}\n onSelect={handleDaySelect}\n locale={locale}\n disabled={disabledMatcher}\n autoFocus\n />\n {withTime && (\n <div className=\"flex items-center gap-2 border-t px-3 py-2\">\n <span className=\"text-sm text-muted-foreground shrink-0\">\n {t('ui.datePicker.timeLabel', 'Time')}:\n </span>\n <TimeInput\n value={selectedDate ? extractTime(selectedDate) : undefined}\n onChange={handleTimeChange}\n minuteStep={minuteStep}\n disabled={!isInteractive || !selectedDate}\n />\n </div>\n )}\n {footer === 'apply-cancel' && (\n <div className=\"flex items-center justify-end gap-2 border-t px-3 py-2\">\n <Button type=\"button\" variant=\"outline\" size=\"sm\" onClick={handleCancel}>\n {t('ui.datePicker.cancelButton', 'Cancel')}\n </Button>\n <Button type=\"button\" size=\"sm\" onClick={handleApply}>\n {t('ui.datePicker.applyButton', 'Apply')}\n </Button>\n </div>\n )}\n {footer === 'today-clear' && (showTodayButton || showClearButton) && (\n <div className=\"flex items-center justify-between gap-2 border-t px-3 py-2\">\n {showTodayButton ? (\n <LinkButton type=\"button\" variant=\"primary\" onClick={handleToday}>\n {t('ui.datePicker.todayButton', 'Today')}\n </LinkButton>\n ) : null}\n {showClearButton ? (\n <LinkButton type=\"button\" variant=\"gray\" onClick={handleClear} className=\"ml-auto\">\n {t('ui.datePicker.clearButton', 'Clear')}\n </LinkButton>\n ) : null}\n </div>\n )}\n </PopoverContent>\n </Popover>\n )\n}\n"],
5
+ "mappings": ";AAsMQ,SAwBE,KAxBF;AApMR,YAAY,WAAW;AAEvB,SAAS,oBAAoB;AAC7B,SAAS,UAAU;AACnB,SAAS,YAAY;AACrB,SAAS,SAAS,gBAAgB,sBAAsB;AACxD,SAAS,gBAAgB;AACzB,SAAS,cAAc;AACvB,SAAS,kBAAkB;AAC3B,SAAS,iBAAiB;AAC1B,SAAS,4BAA4B,yBAAyB,mCAAmC;AAoDjG,SAAS,YAAY,MAAoB;AACvC,SAAO,GAAG,OAAO,KAAK,SAAS,CAAC,EAAE,SAAS,GAAG,GAAG,CAAC,IAAI,OAAO,KAAK,WAAW,CAAC,EAAE,SAAS,GAAG,GAAG,CAAC;AAClG;AAEA,SAAS,gBAAgB,MAAY,MAAoB;AACvD,QAAM,CAAC,UAAU,KAAK,YAAY,GAAG,IAAI,KAAK,MAAM,GAAG;AACvD,QAAM,OAAO,SAAS,SAAS,EAAE;AACjC,QAAM,SAAS,SAAS,WAAW,EAAE;AACrC,QAAM,OAAO,IAAI,KAAK,IAAI;AAC1B,OAAK,SAAS,MAAM,IAAI,IAAI,IAAI,MAAM,MAAM,MAAM,IAAI,IAAI,QAAQ,GAAG,CAAC;AACtE,SAAO;AACT;AAEA,SAAS,WAAW,MAAkB;AACpC,QAAM,OAAO,IAAI,KAAK,IAAI;AAC1B,OAAK,SAAS,GAAG,GAAG,GAAG,CAAC;AACxB,SAAO;AACT;AAEO,SAAS,WAAW;AAAA,EACzB;AAAA,EACA;AAAA,EACA;AAAA,EACA,OAAO;AAAA,EACP,WAAW;AAAA,EACX,WAAW;AAAA,EACX,SAAS;AAAA,EACT;AAAA,EACA,kBAAkB;AAAA,EAClB,kBAAkB;AAAA,EAClB,WAAW;AAAA,EACX,aAAa;AAAA,EACb,QAAQ;AAAA,EACR;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,cAAc;AAAA,EACd,oBAAoB;AACtB,GAAoB;AAClB,QAAM,wBAAwB,iBAAiB,WAAW;AAC1D,QAAM,IAAI,KAAK;AACf,QAAM,CAAC,MAAM,OAAO,IAAI,MAAM,SAAS,KAAK;AAC5C,QAAM,CAAC,OAAO,QAAQ,IAAI,MAAM,SAAsB,SAAS,IAAI;AAEnE,QAAM,gBAAgB,CAAC,YAAY,CAAC;AACpC,QAAM,WAAW,WAAW;AAE5B,QAAM,UAAU,MAAM;AACpB,QAAI,KAAM,UAAS,SAAS,IAAI;AAAA,EAClC,GAAG,CAAC,MAAM,KAAK,CAAC;AAEhB,QAAM,iBAAiB,WACnB,4BAA4B,QAAQ,aAAa,IACjD,wBAAwB,QAAQ,aAAa;AACjD,QAAM,kBAAkB,gBACtB,WACI,EAAE,iCAAiC,oBAAoB,IACvD,EAAE,6BAA6B,aAAa;AAGlD,QAAM,iBAAiB,MAAM,QAAQ,MAAM;AACzC,QAAI,CAAC,MAAO,QAAO;AACnB,WAAO,2BAA2B,OAAO,gBAAgB,MAAM;AAAA,EACjE,GAAG,CAAC,OAAO,gBAAgB,MAAM,CAAC;AAElC,QAAM,kBAAkB,MAAM;AAAA,IAC5B,CAAC,QAA0B;AACzB,UAAI,CAAC,OAAO,CAAC,cAAe;AAC5B,YAAM,YAAY,WAAY,WAAW,QAAQ,QAAS;AAC1D,YAAM,OAAO,WACT,gBAAgB,KAAK,YAAY,YAAY,SAAS,IAAI,OAAO,IACjE,WAAW,GAAG;AAClB,UAAI,UAAU;AACZ,iBAAS,IAAI;AAAA,MACf,OAAO;AACL,iBAAS,IAAI;AACb,YAAI,WAAW,UAAU,sBAAuB,SAAQ,KAAK;AAAA,MAC/D;AAAA,IACF;AAAA,IACA,CAAC,eAAe,UAAU,UAAU,OAAO,OAAO,QAAQ,uBAAuB,QAAQ;AAAA,EAC3F;AAEA,QAAM,mBAAmB,MAAM;AAAA,IAC7B,CAAC,SAAiB;AAChB,YAAM,SAAS,WAAW,QAAQ;AAClC,UAAI,CAAC,OAAQ;AACb,YAAM,OAAO,gBAAgB,QAAQ,IAAI;AACzC,UAAI,SAAU,UAAS,IAAI;AAAA,UACtB,UAAS,IAAI;AAAA,IACpB;AAAA,IACA,CAAC,UAAU,OAAO,OAAO,QAAQ;AAAA,EACnC;AAEA,QAAM,cAAc,MAAM,YAAY,MAAM;AAC1C,aAAS,KAAK;AACd,YAAQ,KAAK;AAAA,EACf,GAAG,CAAC,OAAO,QAAQ,CAAC;AAEpB,QAAM,eAAe,MAAM,YAAY,MAAM;AAC3C,aAAS,SAAS,IAAI;AACtB,YAAQ,KAAK;AAAA,EACf,GAAG,CAAC,KAAK,CAAC;AAEV,QAAM,cAAc,MAAM,YAAY,MAAM;AAC1C,UAAM,QAAQ,WAAW,oBAAI,KAAK,IAAI,WAAW,oBAAI,KAAK,CAAC;AAC3D,aAAS,KAAK;AACd,YAAQ,KAAK;AAAA,EACf,GAAG,CAAC,UAAU,QAAQ,CAAC;AAEvB,QAAM,cAAc,MAAM,YAAY,MAAM;AAC1C,aAAS,IAAI;AACb,YAAQ,KAAK;AAAA,EACf,GAAG,CAAC,QAAQ,CAAC;AAEb,QAAM,kBAAkB,MAAM,QAAQ,MAAM;AAC1C,QAAI,CAAC,WAAW,CAAC,QAAS,QAAO;AACjC,UAAM,WAAiD,CAAC;AACxD,QAAI,QAAS,UAAS,KAAK,EAAE,QAAQ,QAAQ,CAAC;AAC9C,QAAI,QAAS,UAAS,KAAK,EAAE,OAAO,QAAQ,CAAC;AAC7C,WAAO;AAAA,EACT,GAAG,CAAC,SAAS,OAAO,CAAC;AAErB,QAAM,cAAc,SAAS,OAAO,uBAAuB;AAC3D,QAAM,eAAe,WAAW,QAAQ;AAExC,SACE,qBAAC,WAAQ,MAAY,cAAc,gBAAgB,UAAU,QAC3D;AAAA,wBAAC,kBAAe,SAAO,MACrB;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL;AAAA,QACA;AAAA,QACA,cAAY;AAAA,QACZ,oBAAkB;AAAA,QAClB,iBAAc;AAAA,QACd,iBAAe,WAAW,OAAO;AAAA,QACjC,0BAAuB;AAAA,QACvB,aAAU;AAAA,QACV;AAAA,QACA,WAAW;AAAA,UACT;AAAA,UACA;AAAA,UACA;AAAA,UACA;AAAA,UACA;AAAA,UACA;AAAA,UACA,YAAY;AAAA,UACZ,CAAC,kBAAkB;AAAA,UACnB;AAAA,QACF;AAAA,QACA,SAAS,gBAAgB,SAAY,CAAC,UAAU,MAAM,eAAe;AAAA,QAErE;AAAA,8BAAC,gBAAa,WAAU,0CAAyC,eAAY,QAAO;AAAA,UACpF,oBAAC,UAAK,WAAU,mBACb,4BAAkB,iBACrB;AAAA;AAAA;AAAA,IACF,GACF;AAAA,IACA,qBAAC,kBAAe,OAAc,WAAW,GAAG,cAAc,gBAAgB,GACxE;AAAA;AAAA,QAAC;AAAA;AAAA,UACC,MAAK;AAAA,UACL,UAAU,gBAAgB;AAAA,UAC1B,cAAc,gBAAgB;AAAA,UAC9B,UAAU;AAAA,UACV;AAAA,UACA,UAAU;AAAA,UACV,WAAS;AAAA;AAAA,MACX;AAAA,MACC,YACC,qBAAC,SAAI,WAAU,8CACb;AAAA,6BAAC,UAAK,WAAU,0CACb;AAAA,YAAE,2BAA2B,MAAM;AAAA,UAAE;AAAA,WACxC;AAAA,QACA;AAAA,UAAC;AAAA;AAAA,YACC,OAAO,eAAe,YAAY,YAAY,IAAI;AAAA,YAClD,UAAU;AAAA,YACV;AAAA,YACA,UAAU,CAAC,iBAAiB,CAAC;AAAA;AAAA,QAC/B;AAAA,SACF;AAAA,MAED,WAAW,kBACV,qBAAC,SAAI,WAAU,0DACb;AAAA,4BAAC,UAAO,MAAK,UAAS,SAAQ,WAAU,MAAK,MAAK,SAAS,cACxD,YAAE,8BAA8B,QAAQ,GAC3C;AAAA,QACA,oBAAC,UAAO,MAAK,UAAS,MAAK,MAAK,SAAS,aACtC,YAAE,6BAA6B,OAAO,GACzC;AAAA,SACF;AAAA,MAED,WAAW,kBAAkB,mBAAmB,oBAC/C,qBAAC,SAAI,WAAU,8DACZ;AAAA,0BACC,oBAAC,cAAW,MAAK,UAAS,SAAQ,WAAU,SAAS,aAClD,YAAE,6BAA6B,OAAO,GACzC,IACE;AAAA,QACH,kBACC,oBAAC,cAAW,MAAK,UAAS,SAAQ,QAAO,SAAS,aAAa,WAAU,WACtE,YAAE,6BAA6B,OAAO,GACzC,IACE;AAAA,SACN;AAAA,OAEJ;AAAA,KACF;AAEJ;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ui",
3
- "version": "0.6.4-develop.4121.1.0d7f20d229",
3
+ "version": "0.6.4-develop.4133.1.48fc6c8f7b",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -141,13 +141,13 @@
141
141
  "remark-gfm": "^4.0.1"
142
142
  },
143
143
  "peerDependencies": {
144
- "@open-mercato/shared": "0.6.4-develop.4121.1.0d7f20d229",
144
+ "@open-mercato/shared": "0.6.4-develop.4133.1.48fc6c8f7b",
145
145
  "react": ">=18.0.0",
146
146
  "react-dom": ">=18.0.0",
147
147
  "react-is": ">=18.0.0"
148
148
  },
149
149
  "devDependencies": {
150
- "@open-mercato/shared": "0.6.4-develop.4121.1.0d7f20d229",
150
+ "@open-mercato/shared": "0.6.4-develop.4133.1.48fc6c8f7b",
151
151
  "@testing-library/dom": "^10.4.1",
152
152
  "@testing-library/jest-dom": "^6.9.1",
153
153
  "@testing-library/react": "^16.3.1",
@@ -111,6 +111,11 @@ export type AppShellProps = {
111
111
  profileSectionTitle?: string
112
112
  profilePathPrefixes?: string[]
113
113
  mobileSidebarSlot?: React.ReactNode
114
+ /**
115
+ * How long (ms) to keep successfully completed progress operations visible
116
+ * before auto-hiding. Pass `false` or `0` to disable. Defaults to 10 000 ms.
117
+ */
118
+ progressCompletedAutoHideMs?: number | false
114
119
  }
115
120
 
116
121
  type Breadcrumb = Array<{ label: string; href?: string }>
@@ -417,7 +422,7 @@ export function AppShell(props: AppShellProps) {
417
422
  )
418
423
  }
419
424
 
420
- function AppShellBody({ productName, logo, email, canManageUpgradeActions = false, groups, rightHeaderSlot, children, sidebarCollapsedDefault = false, currentTitle, breadcrumb, version, settingsSectionTitle, settingsPathPrefixes = [], settingsSections, profileSections, profileSectionTitle, profilePathPrefixes = [], mobileSidebarSlot }: AppShellProps) {
425
+ function AppShellBody({ productName, logo, email, canManageUpgradeActions = false, groups, rightHeaderSlot, children, sidebarCollapsedDefault = false, currentTitle, breadcrumb, version, settingsSectionTitle, settingsPathPrefixes = [], settingsSections, profileSections, profileSectionTitle, profilePathPrefixes = [], mobileSidebarSlot, progressCompletedAutoHideMs }: AppShellProps) {
421
426
  const pathname = usePathname()
422
427
  const searchParams = useSearchParams()
423
428
  const t = useT()
@@ -1337,7 +1342,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
1337
1342
  )}
1338
1343
  </div>
1339
1344
  </header>
1340
- <ProgressTopBar t={t} className="sticky top-0 z-sticky" />
1345
+ <ProgressTopBar t={t} className="sticky top-0 z-sticky" completedAutoHideMs={progressCompletedAutoHideMs} />
1341
1346
  <main className="flex-1 p-4 lg:p-6 mx-auto w-full max-w-screen-2xl">
1342
1347
  <InjectionSpot spotId={BACKEND_LAYOUT_TOP_INJECTION_SPOT_ID} context={injectionContext} />
1343
1348
  <FlashMessages />
@@ -0,0 +1,179 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as React from 'react'
6
+ import { renderHook, act } from '@testing-library/react'
7
+ import { useAutoHideCompletedJobs } from '../progress/useAutoHideCompletedJobs'
8
+ import type { ProgressJobDto } from '../progress/useProgressPoll'
9
+
10
+ function makeJob(overrides: Partial<ProgressJobDto> = {}): ProgressJobDto {
11
+ return {
12
+ id: 'job-1',
13
+ jobType: 'bulk-delete',
14
+ name: 'Delete selected',
15
+ status: 'completed',
16
+ progressPercent: 100,
17
+ processedCount: 5,
18
+ cancellable: false,
19
+ finishedAt: new Date().toISOString(),
20
+ ...overrides,
21
+ }
22
+ }
23
+
24
+ describe('useAutoHideCompletedJobs', () => {
25
+ beforeEach(() => {
26
+ jest.useFakeTimers()
27
+ })
28
+
29
+ afterEach(() => {
30
+ jest.useRealTimers()
31
+ })
32
+
33
+ it('hides a completed job after the timeout elapses', () => {
34
+ const jobs = [makeJob({ id: 'j1', finishedAt: new Date().toISOString() })]
35
+ const { result } = renderHook(() => useAutoHideCompletedJobs(jobs, 10_000))
36
+
37
+ expect(result.current).toHaveLength(1)
38
+
39
+ act(() => { jest.advanceTimersByTime(10_000) })
40
+
41
+ expect(result.current).toHaveLength(0)
42
+ })
43
+
44
+ it('keeps failed jobs visible after the timeout', () => {
45
+ const jobs = [
46
+ makeJob({ id: 'j-ok', status: 'completed', finishedAt: new Date().toISOString() }),
47
+ makeJob({ id: 'j-fail', status: 'failed', finishedAt: new Date().toISOString() }),
48
+ ]
49
+ const { result } = renderHook(() => useAutoHideCompletedJobs(jobs, 10_000))
50
+
51
+ act(() => { jest.advanceTimersByTime(10_000) })
52
+
53
+ expect(result.current).toHaveLength(1)
54
+ expect(result.current[0].id).toBe('j-fail')
55
+ })
56
+
57
+ it('keeps cancelled jobs visible after the timeout', () => {
58
+ const jobs = [
59
+ makeJob({ id: 'j-cancel', status: 'cancelled', finishedAt: new Date().toISOString() }),
60
+ ]
61
+ const { result } = renderHook(() => useAutoHideCompletedJobs(jobs, 10_000))
62
+
63
+ act(() => { jest.advanceTimersByTime(10_000) })
64
+
65
+ expect(result.current).toHaveLength(1)
66
+ })
67
+
68
+ it('hides each job based on its own finishedAt (no false-positive cascade)', () => {
69
+ const now = Date.now()
70
+ const jobs = [
71
+ makeJob({ id: 'j-early', finishedAt: new Date(now - 8_000).toISOString() }), // finished 8s ago
72
+ makeJob({ id: 'j-late', finishedAt: new Date(now).toISOString() }), // just finished
73
+ ]
74
+ const { result } = renderHook(() => useAutoHideCompletedJobs(jobs, 10_000))
75
+
76
+ // After 2s more, the early job (8s+2s=10s) should expire
77
+ act(() => { jest.advanceTimersByTime(2_000) })
78
+ expect(result.current.map((j) => j.id)).toEqual(['j-late'])
79
+
80
+ // After another 8s, the late job expires too
81
+ act(() => { jest.advanceTimersByTime(8_000) })
82
+ expect(result.current).toHaveLength(0)
83
+ })
84
+
85
+ it('immediately hides completed jobs whose finishedAt is already past the timeout', () => {
86
+ const pastFinishedAt = new Date(Date.now() - 15_000).toISOString() // finished 15s ago
87
+ const jobs = [makeJob({ id: 'j-old', finishedAt: pastFinishedAt })]
88
+ const { result } = renderHook(() => useAutoHideCompletedJobs(jobs, 10_000))
89
+
90
+ // No timer needed — should already be filtered out
91
+ expect(result.current).toHaveLength(0)
92
+ })
93
+
94
+ it('does not hide when timeoutMs is false', () => {
95
+ const jobs = [makeJob({ id: 'j1' })]
96
+ const { result } = renderHook(() => useAutoHideCompletedJobs(jobs, false))
97
+
98
+ act(() => { jest.advanceTimersByTime(60_000) })
99
+
100
+ expect(result.current).toHaveLength(1)
101
+ })
102
+
103
+ it('does not hide when timeoutMs is 0', () => {
104
+ const jobs = [makeJob({ id: 'j1' })]
105
+ const { result } = renderHook(() => useAutoHideCompletedJobs(jobs, 0))
106
+
107
+ act(() => { jest.advanceTimersByTime(60_000) })
108
+
109
+ expect(result.current).toHaveLength(1)
110
+ })
111
+
112
+ it('respects a custom timeout', () => {
113
+ const jobs = [makeJob({ id: 'j1', finishedAt: new Date().toISOString() })]
114
+ const { result } = renderHook(() => useAutoHideCompletedJobs(jobs, 3_000))
115
+
116
+ act(() => { jest.advanceTimersByTime(2_999) })
117
+ expect(result.current).toHaveLength(1)
118
+
119
+ act(() => { jest.advanceTimersByTime(1) })
120
+ expect(result.current).toHaveLength(0)
121
+ })
122
+
123
+ it('does not re-schedule a timer when jobs list updates with the same completed job', () => {
124
+ const job = makeJob({ id: 'j1', finishedAt: new Date().toISOString() })
125
+ const { result, rerender } = renderHook(
126
+ ({ jobs }: { jobs: ProgressJobDto[] }) => useAutoHideCompletedJobs(jobs, 10_000),
127
+ { initialProps: { jobs: [job] } },
128
+ )
129
+
130
+ act(() => { jest.advanceTimersByTime(5_000) })
131
+ // Simulate poll refresh — same job still in list
132
+ rerender({ jobs: [job] })
133
+
134
+ // Timer should expire at original 10s mark, not reset to 15s
135
+ act(() => { jest.advanceTimersByTime(5_000) })
136
+ expect(result.current).toHaveLength(0)
137
+ })
138
+
139
+ it('clears timers on unmount without errors', () => {
140
+ const jobs = [makeJob({ id: 'j1', finishedAt: new Date().toISOString() })]
141
+ const { unmount } = renderHook(() => useAutoHideCompletedJobs(jobs, 10_000))
142
+
143
+ expect(() => {
144
+ unmount()
145
+ jest.advanceTimersByTime(15_000)
146
+ }).not.toThrow()
147
+ })
148
+
149
+ it('treats a malformed finishedAt as "just finished" — does not error and hides after full timeout', () => {
150
+ const jobs = [makeJob({ id: 'j-bad', finishedAt: 'not-a-date' })]
151
+ const { result } = renderHook(() => useAutoHideCompletedJobs(jobs, 10_000))
152
+
153
+ // Should be visible initially (malformed → treated as now)
154
+ expect(result.current).toHaveLength(1)
155
+
156
+ act(() => { jest.advanceTimersByTime(10_000) })
157
+
158
+ expect(result.current).toHaveLength(0)
159
+ })
160
+
161
+ it('stays hidden when list cycles through empty — does not restart timer', () => {
162
+ const job = makeJob({ id: 'j1', finishedAt: new Date().toISOString() })
163
+ const { result, rerender } = renderHook(
164
+ ({ jobs }: { jobs: ProgressJobDto[] }) => useAutoHideCompletedJobs(jobs, 10_000),
165
+ { initialProps: { jobs: [job] } },
166
+ )
167
+
168
+ // Advance past the timeout so job expires
169
+ act(() => { jest.advanceTimersByTime(10_000) })
170
+ expect(result.current).toHaveLength(0)
171
+
172
+ // List goes empty then comes back with the same job
173
+ rerender({ jobs: [] })
174
+ rerender({ jobs: [job] })
175
+
176
+ // Job should stay hidden — scheduledRef prevents re-scheduling
177
+ expect(result.current).toHaveLength(0)
178
+ })
179
+ })
@@ -14,6 +14,7 @@ import { GripVertical, Info, Plus, RefreshCw, Settings2, Trash2, X, Loader2 } fr
14
14
  import { useT } from '@open-mercato/shared/lib/i18n/context'
15
15
  import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
16
16
  import { InjectionSpot } from '../injection/InjectionSpot'
17
+ import { WidgetDataBatchProvider } from './widgetData'
17
18
 
18
19
  type DashboardWidgetSize = 'sm' | 'md' | 'lg'
19
20
 
@@ -429,6 +430,7 @@ export function DashboardScreen() {
429
430
  </div>
430
431
  )}
431
432
 
433
+ <WidgetDataBatchProvider>
432
434
  <div className={cn(
433
435
  'grid gap-3 sm:gap-4 md:gap-6',
434
436
  'grid-cols-1',
@@ -493,6 +495,7 @@ export function DashboardScreen() {
493
495
  )
494
496
  })}
495
497
  </div>
498
+ </WidgetDataBatchProvider>
496
499
 
497
500
  {layout.length === 0 && (
498
501
  <div className="rounded-lg border border-dashed bg-muted/30 p-10 text-center text-sm text-muted-foreground">
@@ -0,0 +1,97 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+ import {
5
+ createWidgetDataBatcher,
6
+ type WidgetDataBatchRequestEntry,
7
+ type WidgetDataBatchResultEntry,
8
+ } from '../widgetDataBatcher'
9
+
10
+ describe('createWidgetDataBatcher', () => {
11
+ beforeEach(() => {
12
+ jest.useFakeTimers()
13
+ })
14
+ afterEach(() => {
15
+ jest.useRealTimers()
16
+ })
17
+
18
+ test('coalesces requests fired within the window into a single send', async () => {
19
+ const send = jest.fn(async (entries: WidgetDataBatchRequestEntry[]) =>
20
+ entries.map((entry): WidgetDataBatchResultEntry => ({ id: entry.id, ok: true, data: entry.request })),
21
+ )
22
+ const batcher = createWidgetDataBatcher({ send, windowMs: 16 })
23
+
24
+ const p1 = batcher.fetch<{ widget: number }>({ widget: 1 })
25
+ const p2 = batcher.fetch<{ widget: number }>({ widget: 2 })
26
+ const p3 = batcher.fetch<{ widget: number }>({ widget: 3 })
27
+
28
+ expect(send).not.toHaveBeenCalled()
29
+ jest.advanceTimersByTime(16)
30
+
31
+ await expect(p1).resolves.toEqual({ widget: 1 })
32
+ await expect(p2).resolves.toEqual({ widget: 2 })
33
+ await expect(p3).resolves.toEqual({ widget: 3 })
34
+
35
+ expect(send).toHaveBeenCalledTimes(1)
36
+ expect(send.mock.calls[0][0]).toHaveLength(3)
37
+ })
38
+
39
+ test('rejects only the failing widget and resolves the rest', async () => {
40
+ const send = jest.fn(async (entries: WidgetDataBatchRequestEntry[]) =>
41
+ entries.map((entry, index): WidgetDataBatchResultEntry =>
42
+ index === 1
43
+ ? { id: entry.id, ok: false, error: 'boom' }
44
+ : { id: entry.id, ok: true, data: entry.request },
45
+ ),
46
+ )
47
+ const batcher = createWidgetDataBatcher({ send, windowMs: 16 })
48
+
49
+ const ok = batcher.fetch({ widget: 'a' })
50
+ const bad = batcher.fetch({ widget: 'b' })
51
+ jest.advanceTimersByTime(16)
52
+
53
+ await expect(ok).resolves.toEqual({ widget: 'a' })
54
+ await expect(bad).rejects.toThrow('boom')
55
+ })
56
+
57
+ test('rejects a caller whose id is missing from the response', async () => {
58
+ const send = jest.fn(async () => [] as WidgetDataBatchResultEntry[])
59
+ const batcher = createWidgetDataBatcher({ send, windowMs: 16 })
60
+
61
+ const p = batcher.fetch({ widget: 1 })
62
+ jest.advanceTimersByTime(16)
63
+
64
+ await expect(p).rejects.toThrow('No result returned for widget')
65
+ })
66
+
67
+ test('rejects all callers when the batch send fails', async () => {
68
+ const send = jest.fn(async () => {
69
+ throw new Error('network down')
70
+ })
71
+ const batcher = createWidgetDataBatcher({ send, windowMs: 16 })
72
+
73
+ const p1 = batcher.fetch({ widget: 1 })
74
+ const p2 = batcher.fetch({ widget: 2 })
75
+ jest.advanceTimersByTime(16)
76
+
77
+ await expect(p1).rejects.toThrow('network down')
78
+ await expect(p2).rejects.toThrow('network down')
79
+ expect(send).toHaveBeenCalledTimes(1)
80
+ })
81
+
82
+ test('splits queues larger than maxBatchSize into multiple sends', async () => {
83
+ const send = jest.fn(async (entries: WidgetDataBatchRequestEntry[]) =>
84
+ entries.map((entry): WidgetDataBatchResultEntry => ({ id: entry.id, ok: true, data: entry.request })),
85
+ )
86
+ const batcher = createWidgetDataBatcher({ send, windowMs: 16, maxBatchSize: 2 })
87
+
88
+ const promises = [1, 2, 3, 4, 5].map((widget) => batcher.fetch({ widget }))
89
+ jest.advanceTimersByTime(16)
90
+
91
+ await Promise.all(promises)
92
+ expect(send).toHaveBeenCalledTimes(3)
93
+ expect(send.mock.calls[0][0]).toHaveLength(2)
94
+ expect(send.mock.calls[1][0]).toHaveLength(2)
95
+ expect(send.mock.calls[2][0]).toHaveLength(1)
96
+ })
97
+ })
@@ -1 +1,2 @@
1
1
  export { DashboardScreen } from './DashboardScreen'
2
+ export { WidgetDataBatchProvider, useWidgetData, type WidgetDataFetcher } from './widgetData'
@@ -0,0 +1,84 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { apiCall } from '../utils/apiCall'
5
+ import {
6
+ createWidgetDataBatcher,
7
+ type WidgetDataBatchResultEntry,
8
+ type WidgetDataBatchSender,
9
+ } from './widgetDataBatcher'
10
+
11
+ const SINGLE_ENDPOINT = '/api/dashboards/widgets/data'
12
+ const BATCH_ENDPOINT = '/api/dashboards/widgets/data/batch'
13
+
14
+
15
+ export type WidgetDataFetcher = <TResponse>(request: unknown) => Promise<TResponse>
16
+
17
+ function readError(result: unknown, fallback: string): string {
18
+ const errorMsg = (result as Record<string, unknown> | null)?.error
19
+ return typeof errorMsg === 'string' ? errorMsg : fallback
20
+ }
21
+
22
+ /**
23
+ * Fallback used when a widget renders outside a {@link WidgetDataBatchProvider}
24
+ * (e.g. a standalone preview). Issues a single un-batched request so widgets
25
+ * keep working without the dashboard host.
26
+ */
27
+ const singleWidgetDataFetch: WidgetDataFetcher = async (request) => {
28
+ const call = await apiCall(SINGLE_ENDPOINT, {
29
+ method: 'POST',
30
+ headers: { 'Content-Type': 'application/json' },
31
+ body: JSON.stringify(request),
32
+ })
33
+ if (!call.ok) {
34
+ throw new Error(readError(call.result, 'Failed to fetch widget data'))
35
+ }
36
+ return call.result as never
37
+ }
38
+
39
+ const WidgetDataContext = React.createContext<WidgetDataFetcher>(singleWidgetDataFetch)
40
+
41
+ const batchSender: WidgetDataBatchSender = async (entries) => {
42
+ const call = await apiCall<{ results?: WidgetDataBatchResultEntry[] }>(BATCH_ENDPOINT, {
43
+ method: 'POST',
44
+ headers: { 'Content-Type': 'application/json' },
45
+ body: JSON.stringify({ requests: entries }),
46
+ })
47
+ if (!call.ok || !call.result || !Array.isArray(call.result.results)) {
48
+ throw new Error(readError(call.result, 'Failed to fetch widget data'))
49
+ }
50
+ return call.result.results
51
+ }
52
+
53
+ export type WidgetDataBatchProviderProps = {
54
+ children: React.ReactNode
55
+ windowMs?: number
56
+ maxBatchSize?: number
57
+ }
58
+
59
+ /**
60
+ * Provides a batched widget-data fetcher to descendant dashboard widgets.
61
+ * Requests fired within a short window collapse into one POST to the batch
62
+ * endpoint, so a first dashboard render issues a single authenticated request
63
+ * instead of one per widget (see #2273).
64
+ */
65
+ export function WidgetDataBatchProvider({
66
+ children,
67
+ windowMs,
68
+ maxBatchSize,
69
+ }: WidgetDataBatchProviderProps): React.ReactElement {
70
+ const batcher = React.useMemo(
71
+ () => createWidgetDataBatcher({ send: batchSender, windowMs, maxBatchSize }),
72
+ [windowMs, maxBatchSize],
73
+ )
74
+ const fetcher = React.useCallback<WidgetDataFetcher>(
75
+ (request) => batcher.fetch(request),
76
+ [batcher],
77
+ )
78
+ return <WidgetDataContext.Provider value={fetcher}>{children}</WidgetDataContext.Provider>
79
+ }
80
+
81
+ /** Returns the active widget-data fetcher (batched when inside a provider). */
82
+ export function useWidgetData(): WidgetDataFetcher {
83
+ return React.useContext(WidgetDataContext)
84
+ }
@@ -0,0 +1,100 @@
1
+ export type WidgetDataBatchRequestEntry = { id: string; request: unknown }
2
+
3
+ export type WidgetDataBatchResultEntry =
4
+ | { id: string; ok: true; data: unknown }
5
+ | { id: string; ok: false; error: string }
6
+
7
+ export type WidgetDataBatchSender = (
8
+ entries: WidgetDataBatchRequestEntry[],
9
+ ) => Promise<WidgetDataBatchResultEntry[]>
10
+
11
+ export type WidgetDataBatcherOptions = {
12
+ send: WidgetDataBatchSender
13
+ /** Coalescing window in ms. Widget requests fired within the window share one HTTP call. */
14
+ windowMs?: number
15
+ /** Upper bound on requests per HTTP call; larger queues are split into multiple sends. */
16
+ maxBatchSize?: number
17
+ }
18
+
19
+ export type WidgetDataBatcher = {
20
+ fetch: <TResponse>(request: unknown) => Promise<TResponse>
21
+ }
22
+
23
+ type PendingCall = {
24
+ id: string
25
+ request: unknown
26
+ resolve: (value: unknown) => void
27
+ reject: (error: unknown) => void
28
+ }
29
+
30
+ const DEFAULT_WINDOW_MS = 16
31
+ const DEFAULT_MAX_BATCH_SIZE = 50
32
+
33
+ /**
34
+ * Coalesces independent widget-data requests fired within a short window into a
35
+ * single batch call. Each call resolves with its own slice of the batch result,
36
+ * so callers stay decoupled from one another while collapsing N HTTP requests
37
+ * (and N server-side container/RBAC/org-scope rebuilds) into one (see #2273).
38
+ */
39
+ export function createWidgetDataBatcher(options: WidgetDataBatcherOptions): WidgetDataBatcher {
40
+ const windowMs = options.windowMs ?? DEFAULT_WINDOW_MS
41
+ const maxBatchSize = options.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE
42
+
43
+ let queue: PendingCall[] = []
44
+ let timer: ReturnType<typeof setTimeout> | null = null
45
+ let counter = 0
46
+
47
+ const flush = () => {
48
+ timer = null
49
+ const pending = queue
50
+ queue = []
51
+ if (pending.length === 0) return
52
+
53
+ for (let start = 0; start < pending.length; start += maxBatchSize) {
54
+ const chunk = pending.slice(start, start + maxBatchSize)
55
+ void dispatchChunk(chunk)
56
+ }
57
+ }
58
+
59
+ const dispatchChunk = async (chunk: PendingCall[]) => {
60
+ try {
61
+ const results = await options.send(chunk.map((call) => ({ id: call.id, request: call.request })))
62
+ const byId = new Map<string, WidgetDataBatchResultEntry>()
63
+ for (const result of results) byId.set(result.id, result)
64
+
65
+ for (const call of chunk) {
66
+ const result = byId.get(call.id)
67
+ if (!result) {
68
+ call.reject(new Error('No result returned for widget'))
69
+ } else if (result.ok) {
70
+ call.resolve(result.data)
71
+ } else {
72
+ call.reject(new Error(result.error))
73
+ }
74
+ }
75
+ } catch (error) {
76
+ for (const call of chunk) call.reject(error)
77
+ }
78
+ }
79
+
80
+ const scheduleFlush = () => {
81
+ if (timer != null) return
82
+ timer = setTimeout(flush, windowMs)
83
+ }
84
+
85
+ return {
86
+ fetch<TResponse>(request: unknown): Promise<TResponse> {
87
+ counter += 1
88
+ const id = `w${counter}`
89
+ return new Promise<TResponse>((resolve, reject) => {
90
+ queue.push({
91
+ id,
92
+ request,
93
+ resolve: (value) => resolve(value as TResponse),
94
+ reject,
95
+ })
96
+ scheduleFlush()
97
+ })
98
+ },
99
+ }
100
+ }