@hailer/mcp 1.3.14 → 1.3.23

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 (57) hide show
  1. package/.claude/skills/create-and-publish-app/SKILL.md +44 -56
  2. package/.claude/skills/hailer-app-builder/SKILL.md +414 -970
  3. package/.claude/skills/hailer-app-primitives/SKILL.md +742 -0
  4. package/.claude/skills/hailer-apps-pictures/SKILL.md +191 -87
  5. package/.claude/skills/hailer-design-patterns/SKILL.md +317 -0
  6. package/.claude/skills/hailer-design-system/SKILL.md +202 -149
  7. package/.claude/skills/hailer-workflow-archetypes/SKILL.md +301 -0
  8. package/.claude/skills/insight-join-patterns/SKILL.md +313 -0
  9. package/.claude/skills/publish-hailer-app/SKILL.md +211 -0
  10. package/.claude/skills/sdk-activity-patterns/SKILL.md +257 -105
  11. package/.claude/skills/sdk-function-fields/SKILL.md +253 -492
  12. package/.claude/skills/sdk-insight-calculations/SKILL.md +364 -0
  13. package/.claude/skills/sdk-insight-queries/SKILL.md +192 -397
  14. package/.claude/skills/sdk-ws-config-skill/SKILL.md +265 -720
  15. package/.claude/skills/tool-response-verification/SKILL.md +143 -0
  16. package/CLAUDE.md +50 -19
  17. package/dist/bot/bot.d.ts.map +1 -1
  18. package/dist/bot/bot.js +26 -34
  19. package/dist/bot/bot.js.map +1 -1
  20. package/dist/bot/services/helper-prompt.d.ts +1 -1
  21. package/dist/bot/services/helper-prompt.d.ts.map +1 -1
  22. package/dist/bot/services/helper-prompt.js +62 -82
  23. package/dist/bot/services/helper-prompt.js.map +1 -1
  24. package/dist/bot/services/system-prompt.js +4 -4
  25. package/dist/config.d.ts +1 -0
  26. package/dist/config.d.ts.map +1 -1
  27. package/dist/config.js +6 -0
  28. package/dist/config.js.map +1 -1
  29. package/dist/mcp/hailer-rpc.d.ts +1 -0
  30. package/dist/mcp/hailer-rpc.d.ts.map +1 -1
  31. package/dist/mcp/hailer-rpc.js +4 -0
  32. package/dist/mcp/hailer-rpc.js.map +1 -1
  33. package/dist/mcp/tools/activity.d.ts.map +1 -1
  34. package/dist/mcp/tools/activity.js +8 -2
  35. package/dist/mcp/tools/activity.js.map +1 -1
  36. package/dist/mcp/tools/app-core.d.ts.map +1 -1
  37. package/dist/mcp/tools/app-core.js +6 -3
  38. package/dist/mcp/tools/app-core.js.map +1 -1
  39. package/dist/mcp/tools/app-marketplace.d.ts.map +1 -1
  40. package/dist/mcp/tools/app-marketplace.js +5 -1
  41. package/dist/mcp/tools/app-marketplace.js.map +1 -1
  42. package/dist/mcp/webhook-handler.d.ts.map +1 -1
  43. package/dist/mcp/webhook-handler.js +27 -0
  44. package/dist/mcp/webhook-handler.js.map +1 -1
  45. package/dist/public-chat/graduate.d.ts.map +1 -1
  46. package/dist/public-chat/graduate.js +153 -53
  47. package/dist/public-chat/graduate.js.map +1 -1
  48. package/dist/public-chat/rate-limit.js +1 -1
  49. package/dist/public-chat/rate-limit.js.map +1 -1
  50. package/dist/public-chat/studio-prewarm.js +2 -2
  51. package/dist/public-chat/studio-prewarm.js.map +1 -1
  52. package/dist/public-chat/system-prompt.d.ts.map +1 -1
  53. package/dist/public-chat/system-prompt.js +41 -25
  54. package/dist/public-chat/system-prompt.js.map +1 -1
  55. package/package.json +1 -1
  56. package/.claude/skills/hailer-project-protocol/SKILL.md +0 -398
  57. package/.opencode/package-lock.json +0 -117
@@ -0,0 +1,742 @@
1
+ ---
2
+ name: hailer-app-primitives
3
+ description: Canonical UI primitives for Hailer apps — load when building confirm dialogs, loading/empty/error states, searchable dropdowns, date pickers, or REST client wrappers; full source included
4
+ version: 0.5.0
5
+ triggers:
6
+ - ConfirmDialog
7
+ - AlertDialog
8
+ - LoadingState
9
+ - EmptyState
10
+ - ErrorState
11
+ - SearchableSelect
12
+ - DatePicker
13
+ - DateRangePicker
14
+ - hailerRest
15
+ - REST from app
16
+ - confirm dialog
17
+ - delete dialog
18
+ - loading state
19
+ - empty state
20
+ - searchable dropdown
21
+ ---
22
+
23
+ # Hailer App Primitives
24
+
25
+ Canonical UI components extracted from production apps. Copy and adapt props — do not re-derive the look. All components use Hailer Design System (Chakra UI v2 + Hailer theme). SDK-free; works in any Hailer app.
26
+
27
+ **Dependency note:** `SearchableSelect` and `SearchableSelectMulti` import icons from `react-icons/fi`, which is **not** shipped by `@hailer/create-app` (neither `react-icons` nor `@chakra-ui/icons` is a template dependency — `@chakra-ui/react` does not bundle icons). Either run `npm install react-icons`, or swap in the template's bundled Hailer icons from `src/hailer/theme/icons/`: `FiChevronDown` → `HailerLargeChevronDown`, `FiX` → `HailerXSmall`, `FiCheck` → `HailerTickSmall`.
28
+
29
+ **Important decision for Studio:** The hub framework auto-injects individual primitive source skills via file-pattern matching. Studio has no such injection mechanism, so **all source is inlined here**. Load this skill whenever you need any primitive.
30
+
31
+ ---
32
+
33
+ ## Built primitives
34
+
35
+ | Primitive | When to use |
36
+ |-----------|-------------|
37
+ | **ConfirmDialog** | Destructive actions (delete, archive) and non-destructive confirmations (submit, publish) |
38
+ | **LoadingState / EmptyState / ErrorState** | Every data-fetching view needs all three |
39
+ | **SearchableSelect** (single) | Picking one option from a long list (> ~8 items) with keyboard nav |
40
+ | **SearchableSelectMulti** | Filtering/tagging where the user picks several display options |
41
+ | **DatePicker** | Single date field in forms |
42
+ | **DateRangePicker** | Start + end date filter |
43
+ | **dateInputToMs / toDateInputValue** | Convert between `YYYY-MM-DD` strings and Hailer's Unix-milliseconds date storage |
44
+ | **hailerRest** | Thin authenticated REST client over `hailer.http.fetch` (v2 only) |
45
+
46
+ ---
47
+
48
+ ## ConfirmDialog
49
+
50
+ A general-purpose confirm/alert dialog. Defaults to destructive-delete intent (`colorScheme="red"`) but fully configurable.
51
+
52
+ **When to use:** Destructive actions (delete, archive, remove) and non-destructive confirmations (submit, publish — override `confirmColorScheme` and `confirmText`).
53
+
54
+ **When NOT to use:** Form submission (use a `<form>` with a submit button), multi-step flows (use a `<Modal>` with stepper), informational alerts with no action (use `<Alert>` inline).
55
+
56
+ ```tsx
57
+ import {
58
+ AlertDialog, AlertDialogBody, AlertDialogContent, AlertDialogFooter,
59
+ AlertDialogHeader, AlertDialogOverlay, Button,
60
+ } from '@chakra-ui/react';
61
+ import React, { useRef } from 'react';
62
+
63
+ interface ConfirmDialogProps {
64
+ isOpen: boolean;
65
+ onClose: () => void;
66
+ onConfirm: () => void;
67
+ title: string;
68
+ bodyText?: string;
69
+ cancelText?: string;
70
+ confirmText?: string;
71
+ /** Chakra colorScheme for the confirm button. Default: 'red' (destructive). */
72
+ confirmColorScheme?: string;
73
+ }
74
+
75
+ const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
76
+ isOpen, onClose, onConfirm, title,
77
+ bodyText = 'Are you sure?',
78
+ cancelText = 'Cancel',
79
+ confirmText = 'Delete',
80
+ confirmColorScheme = 'red',
81
+ }) => {
82
+ const cancelRef = useRef<HTMLButtonElement | null>(null);
83
+
84
+ return (
85
+ <AlertDialog isOpen={isOpen} leastDestructiveRef={cancelRef} onClose={onClose} isCentered>
86
+ <AlertDialogOverlay>
87
+ <AlertDialogContent>
88
+ <AlertDialogHeader>{title}</AlertDialogHeader>
89
+ <AlertDialogBody>{bodyText}</AlertDialogBody>
90
+ <AlertDialogFooter>
91
+ <Button ref={cancelRef} onClick={onClose}>{cancelText}</Button>
92
+ <Button colorScheme={confirmColorScheme} onClick={onConfirm} ml={3}>
93
+ {confirmText}
94
+ </Button>
95
+ </AlertDialogFooter>
96
+ </AlertDialogContent>
97
+ </AlertDialogOverlay>
98
+ </AlertDialog>
99
+ );
100
+ };
101
+
102
+ export default ConfirmDialog;
103
+ ```
104
+
105
+ **Props:**
106
+
107
+ | Prop | Type | Default | Notes |
108
+ |------|------|---------|-------|
109
+ | `isOpen` | `boolean` | — | Required |
110
+ | `onClose` | `() => void` | — | Required |
111
+ | `onConfirm` | `() => void` | — | Required |
112
+ | `title` | `string` | — | Required |
113
+ | `bodyText` | `string` | `'Are you sure?'` | |
114
+ | `cancelText` | `string` | `'Cancel'` | |
115
+ | `confirmText` | `string` | `'Delete'` | |
116
+ | `confirmColorScheme` | `string` | `'red'` | Use `'green'` for non-destructive |
117
+
118
+ **Usage:**
119
+
120
+ ```tsx
121
+ // Destructive delete (defaults)
122
+ <ConfirmDialog
123
+ isOpen={isDeleteOpen}
124
+ onClose={onDeleteClose}
125
+ onConfirm={handleDelete}
126
+ title="Poista rivi"
127
+ bodyText="Haluatko varmasti poistaa tämän rivin? Toimintoa ei voi peruuttaa."
128
+ confirmText="Poista"
129
+ />
130
+
131
+ // Non-destructive confirm
132
+ <ConfirmDialog
133
+ isOpen={isSubmitOpen}
134
+ onClose={onSubmitClose}
135
+ onConfirm={handleSubmit}
136
+ title="Lähetä lomake"
137
+ bodyText="Haluatko lähettää lomakkeen?"
138
+ confirmText="Lähetä"
139
+ confirmColorScheme="green"
140
+ />
141
+
142
+ // State pattern (pair with useDisclosure):
143
+ const { isOpen, onOpen, onClose } = useDisclosure();
144
+ <Button colorScheme="red" onClick={onOpen}>Poista</Button>
145
+ <ConfirmDialog
146
+ isOpen={isOpen}
147
+ onClose={onClose}
148
+ onConfirm={() => { handleDelete(); onClose(); }}
149
+ title="Poista kohde"
150
+ />
151
+ ```
152
+
153
+ ---
154
+
155
+ ## LoadingState / EmptyState / ErrorState
156
+
157
+ Three composable state-feedback components. Every data-fetching view needs all three.
158
+
159
+ ```tsx
160
+ import { Box, Button, Spinner, Text, VStack } from '@chakra-ui/react';
161
+ import React from 'react';
162
+
163
+ // ── LoadingState ──────────────────────────────────────────────────────────────
164
+
165
+ interface LoadingStateProps {
166
+ /** Label shown below the spinner. Default: 'Ladataan…' */
167
+ label?: string;
168
+ }
169
+
170
+ export function LoadingState({ label = 'Ladataan…' }: LoadingStateProps) {
171
+ return (
172
+ <Box py={12} textAlign="center">
173
+ <VStack spacing={3}>
174
+ <Spinner size="xl" color="green.500" thickness="3px" />
175
+ <Text color="subtleText" fontSize="sm">{label}</Text>
176
+ </VStack>
177
+ </Box>
178
+ );
179
+ }
180
+
181
+ // ── EmptyState ────────────────────────────────────────────────────────────────
182
+
183
+ interface EmptyStateProps {
184
+ /** Main message. Default: 'Ei tietoja näytettäväksi' */
185
+ label?: string;
186
+ /** Optional icon or illustration above the label. */
187
+ icon?: React.ReactNode;
188
+ }
189
+
190
+ export function EmptyState({ label = 'Ei tietoja näytettäväksi', icon }: EmptyStateProps) {
191
+ return (
192
+ <Box py={12} textAlign="center">
193
+ <VStack spacing={3}>
194
+ {icon}
195
+ <Text color="subtleText" fontSize="md">{label}</Text>
196
+ </VStack>
197
+ </Box>
198
+ );
199
+ }
200
+
201
+ // ── ErrorState ────────────────────────────────────────────────────────────────
202
+
203
+ interface ErrorStateProps {
204
+ /** Error message to display. */
205
+ message: string;
206
+ /** Optional retry callback. When provided, a retry button is rendered. */
207
+ onRetry?: () => void;
208
+ /** Retry button label. Default: 'Yritä uudelleen' */
209
+ retryLabel?: string;
210
+ }
211
+
212
+ export function ErrorState({ message, onRetry, retryLabel = 'Yritä uudelleen' }: ErrorStateProps) {
213
+ return (
214
+ <Box p={8} textAlign="center">
215
+ <Text color="red.500" mb={3}>{message}</Text>
216
+ {onRetry && (
217
+ <Button colorScheme="gray" size="sm" onClick={onRetry}>{retryLabel}</Button>
218
+ )}
219
+ </Box>
220
+ );
221
+ }
222
+ ```
223
+
224
+ **Usage:**
225
+
226
+ ```tsx
227
+ if (isLoading) return <LoadingState />;
228
+ if (error) return <ErrorState message={error.message} onRetry={refetch} />;
229
+ if (!items.length) return <EmptyState label="Ei tilauksia löytynyt" />;
230
+ ```
231
+
232
+ **Note:** `color="green.500"` matches the Hailer brand. Do not use `color="blue.500"` for the spinner.
233
+
234
+ ---
235
+
236
+ ## SearchableSelect (single)
237
+
238
+ Combobox-style dropdown with inline search, keyboard navigation (↑↓ Enter Esc), optional badge, and optional clear button.
239
+
240
+ **When to use:** Picking one option from a long list (> ~8 items).
241
+ **When NOT to use:** Short lists (≤ 8 items) — use Chakra `<Select>` or `<RadioGroup>`.
242
+
243
+ ```tsx
244
+ import { useState, useRef, useEffect, useMemo, useCallback, KeyboardEvent } from 'react';
245
+ import {
246
+ Box, HStack, IconButton, Input, InputGroup, InputRightElement,
247
+ Popover, PopoverBody, PopoverContent, PopoverTrigger, Text, useColorModeValue,
248
+ } from '@chakra-ui/react';
249
+ import { FiChevronDown, FiX } from 'react-icons/fi';
250
+
251
+ export interface SelectOption {
252
+ _id: string;
253
+ name: string;
254
+ badge?: string;
255
+ }
256
+
257
+ interface SearchableSelectProps {
258
+ value: string | null;
259
+ onChange: (id: string) => void;
260
+ options: SelectOption[];
261
+ placeholder: string;
262
+ isDisabled?: boolean;
263
+ isInvalid?: boolean;
264
+ allowClear?: boolean;
265
+ }
266
+
267
+ export default function SearchableSelect({
268
+ value, onChange, options, placeholder,
269
+ isDisabled = false, isInvalid = false, allowClear = false,
270
+ }: SearchableSelectProps) {
271
+ const [isOpen, setIsOpen] = useState(false);
272
+ const [search, setSearch] = useState('');
273
+ const [highlightIndex, setHighlightIndex] = useState(0);
274
+ const inputRef = useRef<HTMLInputElement>(null);
275
+ const listRef = useRef<HTMLDivElement>(null);
276
+
277
+ const selectedOption = useMemo(() => options.find((o) => o._id === value) ?? null, [options, value]);
278
+ const filtered = useMemo(() => {
279
+ if (!search.trim()) return options;
280
+ const q = search.toLowerCase();
281
+ return options.filter((o) => o.name.toLowerCase().includes(q));
282
+ }, [options, search]);
283
+
284
+ useEffect(() => { setHighlightIndex(0); }, [search, isOpen]);
285
+ useEffect(() => {
286
+ if (!isOpen || !listRef.current) return;
287
+ const el = listRef.current.querySelector<HTMLElement>(`[data-option-index="${highlightIndex}"]`);
288
+ if (el) el.scrollIntoView({ block: 'nearest' });
289
+ }, [highlightIndex, isOpen]);
290
+
291
+ const open = useCallback(() => {
292
+ if (isDisabled) return;
293
+ setIsOpen(true); setSearch('');
294
+ setTimeout(() => inputRef.current?.focus(), 0);
295
+ }, [isDisabled]);
296
+ const close = useCallback(() => { setIsOpen(false); setSearch(''); }, []);
297
+ const select = useCallback((id: string) => { onChange(id); close(); }, [onChange, close]);
298
+
299
+ const handleKeyDown = useCallback((e: KeyboardEvent<HTMLInputElement>) => {
300
+ if (e.key === 'Escape') { e.preventDefault(); close(); return; }
301
+ if (e.key === 'ArrowDown') { e.preventDefault(); setHighlightIndex((i) => Math.min(i + 1, Math.max(0, filtered.length - 1))); return; }
302
+ if (e.key === 'ArrowUp') { e.preventDefault(); setHighlightIndex((i) => Math.max(0, i - 1)); return; }
303
+ if (e.key === 'Enter') { e.preventDefault(); const opt = filtered[highlightIndex]; if (opt) select(opt._id); return; }
304
+ }, [filtered, highlightIndex, select, close]);
305
+
306
+ const triggerBg = useColorModeValue('white', 'gray.700');
307
+ const triggerBorder = useColorModeValue(isInvalid ? 'red.500' : 'gray.200', isInvalid ? 'red.300' : 'gray.600');
308
+ const hoverBg = useColorModeValue('gray.100', 'gray.600');
309
+ const highlightBg = useColorModeValue('green.50', 'green.900');
310
+ const placeholderColor = useColorModeValue('gray.500', 'gray.400');
311
+
312
+ return (
313
+ <Popover isOpen={isOpen} onClose={close} placement="bottom-start" autoFocus={false} matchWidth isLazy>
314
+ <PopoverTrigger>
315
+ <HStack
316
+ as="button" type="button" onClick={() => (isOpen ? close() : open())}
317
+ spacing={0} w="100%" h="40px" px={3}
318
+ bg={triggerBg} border="1px solid" borderColor={triggerBorder} borderRadius="md"
319
+ cursor={isDisabled ? 'not-allowed' : 'pointer'} opacity={isDisabled ? 0.6 : 1}
320
+ _hover={{ borderColor: isDisabled ? triggerBorder : 'gray.300' }} textAlign="left"
321
+ >
322
+ <Text flex={1} noOfLines={1} color={selectedOption ? undefined : placeholderColor} fontSize="md">
323
+ {selectedOption ? selectedOption.name : placeholder}
324
+ </Text>
325
+ {selectedOption?.badge && <Text fontSize="sm" color="subtleText" mr={2}>{selectedOption.badge}</Text>}
326
+ {allowClear && selectedOption && !isDisabled && (
327
+ <IconButton icon={<FiX />} aria-label="Tyhjennä valinta" size="xs" variant="ghost"
328
+ onClick={(e) => { e.stopPropagation(); onChange(''); }} mr={1} />
329
+ )}
330
+ <Box color="gray.500"><FiChevronDown /></Box>
331
+ </HStack>
332
+ </PopoverTrigger>
333
+ <PopoverContent w="100%">
334
+ <PopoverBody p={2}>
335
+ <InputGroup size="sm" mb={2}>
336
+ <Input ref={inputRef} placeholder="Hae..." value={search}
337
+ onChange={(e) => setSearch(e.target.value)} onKeyDown={handleKeyDown} autoFocus />
338
+ {search && (
339
+ <InputRightElement>
340
+ <IconButton icon={<FiX />} aria-label="Tyhjennä haku" size="xs" variant="ghost" onClick={() => setSearch('')} />
341
+ </InputRightElement>
342
+ )}
343
+ </InputGroup>
344
+ <Box ref={listRef} maxH="240px" overflowY="auto">
345
+ {filtered.length === 0 ? (
346
+ <Text fontSize="sm" color="subtleText" p={2}>Ei tuloksia</Text>
347
+ ) : (
348
+ filtered.map((opt, idx) => (
349
+ <HStack key={opt._id} data-option-index={idx} px={3} py={2} borderRadius="md" cursor="pointer"
350
+ bg={idx === highlightIndex ? highlightBg : 'transparent'} _hover={{ bg: hoverBg }}
351
+ onClick={() => select(opt._id)} spacing={2}>
352
+ <Text flex={1} fontSize="sm" noOfLines={1}>{opt.name}</Text>
353
+ {opt.badge && <Text fontSize="xs" color="subtleText">{opt.badge}</Text>}
354
+ </HStack>
355
+ ))
356
+ )}
357
+ </Box>
358
+ </PopoverBody>
359
+ </PopoverContent>
360
+ </Popover>
361
+ );
362
+ }
363
+ ```
364
+
365
+ **Usage:**
366
+
367
+ ```tsx
368
+ const [selectedId, setSelectedId] = useState<string | null>(null);
369
+ const options = activities.map(a => ({ _id: a._id, name: a.name }));
370
+
371
+ <SearchableSelect
372
+ value={selectedId}
373
+ onChange={setSelectedId}
374
+ options={options}
375
+ placeholder="Valitse projekti..."
376
+ allowClear
377
+ />
378
+ ```
379
+
380
+ ---
381
+
382
+ ## SearchableSelectMulti
383
+
384
+ Multi-select combobox. Shows tags for 1–2 selected, count label for 3+. Stays open for multi-pick.
385
+
386
+ **Important:** Hailer has no multi-select field type. This is a pure UI affordance — map each selected value to separate Hailer field writes when saving.
387
+
388
+ ```tsx
389
+ import { useState, useRef, useMemo } from 'react';
390
+ import {
391
+ Box, HStack, IconButton, Input, InputGroup, InputRightElement,
392
+ List, ListItem, Popover, PopoverAnchor, PopoverContent,
393
+ Tag, TagCloseButton, TagLabel, Text, useColorModeValue, useDisclosure, Wrap, WrapItem,
394
+ } from '@chakra-ui/react';
395
+ import { FiCheck, FiChevronDown, FiX } from 'react-icons/fi';
396
+
397
+ interface SearchableSelectMultiProps {
398
+ placeholder: string;
399
+ options: string[];
400
+ value: string[];
401
+ onChange: (value: string[]) => void;
402
+ maxW?: string;
403
+ }
404
+
405
+ export default function SearchableSelectMulti({
406
+ placeholder, options, value, onChange, maxW = '280px',
407
+ }: SearchableSelectMultiProps) {
408
+ const { isOpen, onOpen, onClose } = useDisclosure();
409
+ const [search, setSearch] = useState('');
410
+ const inputRef = useRef<HTMLInputElement>(null);
411
+ const selectedSet = useMemo(() => new Set(value), [value]);
412
+ const filtered = useMemo(() => {
413
+ if (!search) return options;
414
+ const lower = search.toLowerCase();
415
+ return options.filter((opt) => opt.toLowerCase().includes(lower));
416
+ }, [options, search]);
417
+
418
+ const hoverBg = useColorModeValue('gray.100', 'gray.600');
419
+ const selectedBg = useColorModeValue('green.50', 'rgba(72, 187, 120, 0.12)');
420
+ const popBg = useColorModeValue('white', 'gray.700');
421
+ const borderColor = useColorModeValue('gray.200', 'gray.600');
422
+ const tickColor = useColorModeValue('green.500', 'green.300');
423
+
424
+ const handleToggle = (opt: string) => {
425
+ onChange(selectedSet.has(opt) ? value.filter((v) => v !== opt) : [...value, opt]);
426
+ };
427
+ const handleClear = () => { onChange([]); setSearch(''); onClose(); };
428
+ const handleRemoveTag = (opt: string) => onChange(value.filter((v) => v !== opt));
429
+
430
+ const triggerContent = () => {
431
+ if (value.length === 0) return null;
432
+ if (value.length <= 2) {
433
+ return (
434
+ <Wrap spacing={1} overflow="hidden" maxW="calc(100% - 28px)">
435
+ {value.map((v) => (
436
+ <WrapItem key={v}>
437
+ <Tag size="sm" colorScheme="green" variant="subtle" borderRadius="full">
438
+ <TagLabel fontSize="xs" maxW="100px" isTruncated>{v}</TagLabel>
439
+ <TagCloseButton onClick={(e) => { e.stopPropagation(); handleRemoveTag(v); }} />
440
+ </Tag>
441
+ </WrapItem>
442
+ ))}
443
+ </Wrap>
444
+ );
445
+ }
446
+ return <Text fontSize="sm" color="bodyText" isTruncated>{value.length} valittu</Text>;
447
+ };
448
+
449
+ return (
450
+ <Popover isOpen={isOpen} onClose={onClose} autoFocus={false} matchWidth placement="bottom-start">
451
+ <PopoverAnchor>
452
+ <Box maxW={maxW} minW="180px">
453
+ {isOpen ? (
454
+ <InputGroup size="sm">
455
+ <Input ref={inputRef} placeholder="Hae..." value={search}
456
+ onChange={(e) => { setSearch(e.target.value); if (!isOpen) onOpen(); }}
457
+ onFocus={() => { setSearch(''); onOpen(); }}
458
+ onBlur={() => setTimeout(() => { setSearch(''); onClose(); }, 150)}
459
+ autoComplete="off" />
460
+ <InputRightElement w="auto" pr={1}>
461
+ {value.length > 0 ? (
462
+ <IconButton aria-label="Tyhjennä" icon={<FiX />} size="xs" variant="ghost" onClick={handleClear} tabIndex={-1} />
463
+ ) : (
464
+ <Box as={FiChevronDown} boxSize={4} color="gray.400" mr={1} />
465
+ )}
466
+ </InputRightElement>
467
+ </InputGroup>
468
+ ) : (
469
+ <HStack as="button" type="button"
470
+ onClick={() => { onOpen(); setTimeout(() => inputRef.current?.focus(), 0); }}
471
+ h="32px" px={3} w="100%" borderWidth="1px" borderColor={borderColor} borderRadius="md"
472
+ bg="transparent" cursor="pointer" spacing={1}
473
+ _hover={{ borderColor: 'gray.300' }} justify="space-between" align="center" overflow="hidden">
474
+ {value.length === 0
475
+ ? <Text fontSize="sm" color="subtleText" isTruncated>{placeholder}</Text>
476
+ : triggerContent()}
477
+ {value.length > 0 ? (
478
+ <IconButton aria-label="Tyhjennä" icon={<FiX />} size="xs" variant="ghost"
479
+ onClick={(e) => { e.stopPropagation(); handleClear(); }} tabIndex={-1} flexShrink={0} />
480
+ ) : (
481
+ <Box as={FiChevronDown} boxSize={4} color="gray.400" flexShrink={0} />
482
+ )}
483
+ </HStack>
484
+ )}
485
+ </Box>
486
+ </PopoverAnchor>
487
+ <PopoverContent w="100%" maxH="280px" overflowY="auto" border="1px solid" borderColor={borderColor}
488
+ bg={popBg} shadow="md" borderRadius="md" p={0} _focus={{ outline: 'none' }}>
489
+ <List spacing={0}>
490
+ {value.length > 0 && (
491
+ <ListItem px={3} py={1.5} fontSize="sm" cursor="pointer" color="red.500" fontWeight="medium"
492
+ _hover={{ bg: hoverBg }} onMouseDown={(e) => { e.preventDefault(); handleClear(); }}>
493
+ Tyhjennä valinnat
494
+ </ListItem>
495
+ )}
496
+ {filtered.length === 0 && (
497
+ <ListItem px={3} py={2}><Text fontSize="sm" color="subtleText">Ei tuloksia</Text></ListItem>
498
+ )}
499
+ {filtered.map((opt) => {
500
+ const isSelected = selectedSet.has(opt);
501
+ return (
502
+ <ListItem key={opt} px={3} py={1.5} fontSize="sm" cursor="pointer"
503
+ bg={isSelected ? selectedBg : undefined} _hover={{ bg: isSelected ? selectedBg : hoverBg }}
504
+ display="flex" alignItems="center" justifyContent="space-between"
505
+ onMouseDown={(e) => { e.preventDefault(); handleToggle(opt); }}>
506
+ <Text isTruncated>{opt}</Text>
507
+ {isSelected && <Box as={FiCheck} boxSize={4} color={tickColor} flexShrink={0} ml={2} />}
508
+ </ListItem>
509
+ );
510
+ })}
511
+ </List>
512
+ </PopoverContent>
513
+ </Popover>
514
+ );
515
+ }
516
+ ```
517
+
518
+ **Usage:**
519
+
520
+ ```tsx
521
+ const [selectedTypes, setSelectedTypes] = useState<string[]>([]);
522
+
523
+ <SearchableSelectMulti
524
+ value={selectedTypes}
525
+ onChange={setSelectedTypes}
526
+ options={['Toimitus', 'Nouto', 'Palautus', 'Varastointi']}
527
+ placeholder="Kaikki tyypit"
528
+ />
529
+ ```
530
+
531
+ ---
532
+
533
+ ## DatePicker / DateRangePicker
534
+
535
+ Native browser `<input type="date">` — no external calendar library needed.
536
+
537
+ **When to use:** Date fields in forms (single date), date range filters.
538
+ **When NOT to use:** Monthly/weekly calendar grid with events (use FullCalendar instead).
539
+
540
+ ```tsx
541
+ import { Box, FormControl, FormLabel, HStack, Input, Text } from '@chakra-ui/react';
542
+ import React from 'react';
543
+
544
+ // ── DatePicker ────────────────────────────────────────────────────────────────
545
+
546
+ interface DatePickerProps {
547
+ label?: string;
548
+ value: string; // YYYY-MM-DD
549
+ onChange: (value: string) => void;
550
+ min?: string; // YYYY-MM-DD
551
+ max?: string; // YYYY-MM-DD
552
+ isDisabled?: boolean;
553
+ isRequired?: boolean;
554
+ isInvalid?: boolean;
555
+ }
556
+
557
+ export function DatePicker({ label, value, onChange, min, max, isDisabled = false, isRequired = false, isInvalid = false }: DatePickerProps) {
558
+ return (
559
+ <FormControl isDisabled={isDisabled} isRequired={isRequired} isInvalid={isInvalid}>
560
+ {label && <FormLabel>{label}</FormLabel>}
561
+ <Input type="date" value={value} onChange={(e) => onChange(e.target.value)} min={min} max={max} />
562
+ </FormControl>
563
+ );
564
+ }
565
+
566
+ // ── DateRangePicker ───────────────────────────────────────────────────────────
567
+
568
+ interface DateRangePickerProps {
569
+ startLabel?: string;
570
+ endLabel?: string;
571
+ startValue: string; // YYYY-MM-DD
572
+ endValue: string; // YYYY-MM-DD
573
+ onStartChange: (value: string) => void;
574
+ onEndChange: (value: string) => void;
575
+ isDisabled?: boolean;
576
+ }
577
+
578
+ export function DateRangePicker({
579
+ startLabel = 'Alkaa', endLabel = 'Päättyy',
580
+ startValue, endValue, onStartChange, onEndChange, isDisabled = false,
581
+ }: DateRangePickerProps) {
582
+ return (
583
+ <HStack spacing={3} align="flex-end" wrap="wrap">
584
+ <DatePicker label={startLabel} value={startValue}
585
+ onChange={(v) => { onStartChange(v); if (endValue && v && endValue < v) onEndChange(v); }}
586
+ max={endValue || undefined} isDisabled={isDisabled} />
587
+ <Text pb={2} color="subtleText">–</Text>
588
+ <DatePicker label={endLabel} value={endValue} onChange={onEndChange}
589
+ min={startValue || undefined} isDisabled={isDisabled} />
590
+ </HStack>
591
+ );
592
+ }
593
+ ```
594
+
595
+ ## Date ↔ ms conversion helpers
596
+
597
+ Hailer date fields store **Unix milliseconds** (`number`) — confirmed by the API validation schema (`hailer-api/src/validation/sharedSchemas.ts`: `JoiDate` = "milliseconds since 1970-01-01 UTC"). Convert when reading/writing:
598
+
599
+ ```ts
600
+ /** 'YYYY-MM-DD' → Unix milliseconds */
601
+ export const dateInputToMs = (v: string): number =>
602
+ v ? new Date(v).getTime() : 0;
603
+
604
+ /** Unix milliseconds → 'YYYY-MM-DD' */
605
+ export const toDateInputValue = (ms: number): string =>
606
+ ms ? new Date(ms).toISOString().slice(0, 10) : '';
607
+ ```
608
+
609
+ **Usage:**
610
+
611
+ ```tsx
612
+ const [date, setDate] = useState('');
613
+ const [startDate, setStartDate] = useState('');
614
+ const [endDate, setEndDate] = useState('');
615
+
616
+ <DatePicker
617
+ label="Toimituspäivä"
618
+ value={date}
619
+ onChange={setDate}
620
+ min={toDateInputValue(Date.now())}
621
+ />
622
+
623
+ <DateRangePicker
624
+ startValue={startDate} endValue={endDate}
625
+ onStartChange={setStartDate} onEndChange={setEndDate}
626
+ />
627
+
628
+ // Save to Hailer — update takes an ARRAY of {_id, fields}, then options
629
+ hailer.activity.update([
630
+ { _id: activityId, fields: { [FIELDS.deliveryDate]: dateInputToMs(date) } }
631
+ ], {});
632
+ ```
633
+
634
+ ---
635
+
636
+ ## hailerRest — REST client primitive
637
+
638
+ Thin wrapper over `hailer.http.fetch` (app-sdk v2) that handles URL building, JSON body parsing, and error normalisation.
639
+
640
+ **v2 only.** `hailer.http` does not exist on app-sdk v1. Check `package.json` before using.
641
+
642
+ ### Verified signature (from `@hailer/app-sdk` v2 `.d.ts`)
643
+
644
+ ```ts
645
+ // HTTPFetchOptions
646
+ interface HTTPFetchOptions {
647
+ method?: string;
648
+ headers?: Record<string, string>;
649
+ body?: string; // always a string — stringify before passing
650
+ }
651
+
652
+ // HTTPFetchResponse — body is a RAW STRING, not parsed JSON
653
+ interface HTTPFetchResponse {
654
+ status: number;
655
+ statusText: string;
656
+ headers: Record<string, string>;
657
+ body: string;
658
+ }
659
+ ```
660
+
661
+ ### Source
662
+
663
+ ```ts
664
+ const API_BASE = 'https://api.hailer.com';
665
+
666
+ export interface HailerRestError extends Error {
667
+ status?: number;
668
+ body?: unknown;
669
+ }
670
+
671
+ export async function hailerRest<T = unknown>(
672
+ hailer: HailerApi, // the app-sdk v2 instance
673
+ path: string, // '/api/v3/activity.list' or a full api.hailer.com URL
674
+ opts: {
675
+ method?: string;
676
+ body?: unknown; // object → JSON.stringify'd
677
+ headers?: Record<string, string>;
678
+ } = {},
679
+ ): Promise<T> {
680
+ const url = path.startsWith('http') ? path : `${API_BASE}${path}`;
681
+
682
+ const res = await hailer.http.fetch(url, {
683
+ method: opts.method ?? (opts.body !== undefined ? 'POST' : 'GET'),
684
+ headers: { 'Content-Type': 'application/json', ...opts.headers },
685
+ body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
686
+ });
687
+
688
+ let parsed: unknown;
689
+ try { parsed = res.body ? JSON.parse(res.body) : null; }
690
+ catch { parsed = res.body; } // non-JSON (e.g. an nginx 403 HTML page)
691
+
692
+ if (res.status < 200 || res.status >= 300) {
693
+ const err = new Error(
694
+ `hailerRest ${res.status} ${res.statusText} — ${path}`
695
+ ) as HailerRestError;
696
+ err.status = res.status;
697
+ err.body = parsed;
698
+ throw err;
699
+ }
700
+
701
+ return parsed as T;
702
+ }
703
+ ```
704
+
705
+ **Usage:**
706
+
707
+ ```ts
708
+ // Fetch activities from a workflow
709
+ const result = await hailerRest(hailer, '/api/v3/activity.list', {
710
+ body: { processId, filter: { phaseId } },
711
+ });
712
+
713
+ // Explicit method
714
+ const updated = await hailerRest(hailer, `/api/v3/activity.update`, {
715
+ method: 'POST',
716
+ body: { activityId, fields: { [fieldId]: value } },
717
+ });
718
+ ```
719
+
720
+ **Platform requirements:**
721
+ - **Web:** Works out-of-the-box. `api.hailer.com` is on the global allowlist.
722
+ - **iOS:** Requires `https://api.hailer.com` in the app's `allowedUrls` config. Default is empty → 400 "URL not allowed".
723
+
724
+ **Security note:** `hailer.http.fetch` runs as the **current user** — app-side filtering is not an isolation boundary. Elevated privileges or cross-user access belongs in a server-side microservice.
725
+
726
+ ---
727
+
728
+ ## Planned primitives (not yet extracted)
729
+
730
+ Candidates from the app fleet. Use existing app implementations as reference until a primitive lands:
731
+
732
+ - **Stepper / wizard** — 3 independent variants (StepperBar, PhasePipeline, StepIndicator)
733
+ - **Toolbar / FilterBar** — pairs with calendar and list views
734
+ - **Stat / KPI card** — 4 dashboard variants (SummaryCard, KPICards, KpiTile, CountWidget)
735
+ - **StatusBadge** — phase → colored badge
736
+ - **Drawer** (detail / side panel) — generic Chakra wrappers
737
+ - **TimeInput** — auto-format HH:MM
738
+ - **DataTable + states / skeleton** — table-with-loading/empty + configurable skeleton
739
+
740
+ ## Calendar — deferred
741
+
742
+ Both production CalendarToolbar variants wrap FullCalendar + Bootstrap, not Chakra. Use [FullCalendar](https://fullcalendar.io/) (`@fullcalendar/react`) for calendar grid views. Use `DateRangePicker` for date range inputs that don't need a grid view.