@joewinke/jatui 0.1.6 → 0.1.9

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 (30) hide show
  1. package/package.json +2 -1
  2. package/src/lib/components/AvatarUpload.svelte +211 -0
  3. package/src/lib/components/ChipInput.svelte +1 -0
  4. package/src/lib/components/ConfirmModal.svelte +1 -1
  5. package/src/lib/components/EmojiPicker.svelte +2 -0
  6. package/src/lib/components/FilePicker.svelte +419 -0
  7. package/src/lib/components/FilterDropdown.svelte +1 -0
  8. package/src/lib/components/FloatingActionBar.svelte +354 -0
  9. package/src/lib/components/ImageUpload.svelte +1 -0
  10. package/src/lib/components/MilestoneCard.svelte +98 -0
  11. package/src/lib/components/MilestoneTimeline.svelte +91 -0
  12. package/src/lib/components/Modal.svelte +1 -0
  13. package/src/lib/components/PdfThumbnail.svelte +61 -0
  14. package/src/lib/components/ResizableDivider.svelte +3 -1
  15. package/src/lib/components/ResizablePanel.svelte +3 -0
  16. package/src/lib/components/SearchDropdown.svelte +62 -3
  17. package/src/lib/components/SidebarUserFooter.svelte +34 -0
  18. package/src/lib/components/SignaturePad.svelte +2 -2
  19. package/src/lib/components/SortDropdown.svelte +4 -2
  20. package/src/lib/components/VoicePlayer.svelte +1 -0
  21. package/src/lib/components/messaging/ChannelInfoModal.svelte +4 -4
  22. package/src/lib/components/messaging/CreateChannelModal.svelte +7 -7
  23. package/src/lib/components/messaging/MessageAttachment.svelte +4 -1
  24. package/src/lib/components/messaging/MessageInput.svelte +1 -0
  25. package/src/lib/components/messaging/NotificationSettingsModal.svelte +6 -6
  26. package/src/lib/index.ts +26 -1
  27. package/src/lib/types/filePicker.ts +14 -0
  28. package/src/lib/types/milestone.ts +22 -0
  29. package/src/lib/utils/dateFormatters.ts +41 -0
  30. package/src/lib/utils/taskUtils.ts +59 -0
@@ -3,6 +3,7 @@
3
3
  value: string;
4
4
  label: string;
5
5
  icon?: string;
6
+ image?: string; // URL for avatar/thumbnail image
6
7
  };
7
8
 
8
9
  export type SearchDropdownGroup = {
@@ -15,12 +16,16 @@
15
16
  import { tick } from 'svelte';
16
17
  import { slide } from 'svelte/transition';
17
18
 
19
+ import type { Snippet } from 'svelte';
20
+
18
21
  let {
19
22
  value = '',
20
23
  groups = [],
21
24
  placeholder = 'Filter...',
22
25
  disabled = false,
23
26
  displayValue,
27
+ colorFn,
28
+ footer,
24
29
  onChange,
25
30
  }: {
26
31
  value: string;
@@ -28,6 +33,8 @@
28
33
  placeholder?: string;
29
34
  disabled?: boolean;
30
35
  displayValue?: string;
36
+ colorFn?: (value: string) => string | undefined;
37
+ footer?: Snippet;
31
38
  onChange: (value: string) => void;
32
39
  } = $props();
33
40
 
@@ -47,6 +54,8 @@
47
54
 
48
55
  const triggerLabel = $derived(displayValue || selectedOption?.label || value || placeholder);
49
56
  const triggerIcon = $derived(selectedOption?.icon || '');
57
+ const triggerImage = $derived(selectedOption?.image || '');
58
+ const activeColor = $derived(colorFn ? colorFn(value) : undefined);
50
59
 
51
60
  const filteredGroups = $derived.by(() => {
52
61
  if (!searchQuery.trim()) return groups;
@@ -61,6 +70,13 @@
61
70
  return result;
62
71
  });
63
72
 
73
+ // Generate a deterministic hue from a string for initials avatars
74
+ function nameHue(s: string): number {
75
+ let h = 0;
76
+ for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) & 0xffff;
77
+ return h % 360;
78
+ }
79
+
64
80
  function select(optionValue: string) {
65
81
  open = false;
66
82
  searchQuery = '';
@@ -88,11 +104,20 @@
88
104
  type="button"
89
105
  class="sd-trigger"
90
106
  class:sd-disabled={disabled}
107
+ style={activeColor ? `border-left-color: ${activeColor}; border-left-width: 3px; color: ${activeColor};` : ''}
91
108
  onclick={() => { if (!disabled) open = !open; }}
92
109
  {disabled}
93
110
  >
94
111
  <span class="sd-trigger-label">
95
- {#if triggerIcon}<span class="sd-trigger-icon">{triggerIcon}</span>{/if}
112
+ {#if triggerImage}
113
+ <img src={triggerImage} alt="" class="sd-avatar" />
114
+ {:else if triggerIcon}
115
+ <span class="sd-trigger-icon">{triggerIcon}</span>
116
+ {:else if selectedOption && selectedOption.value}
117
+ <span class="sd-avatar sd-avatar-initials" style="background:oklch(0.55 0.15 {nameHue(selectedOption.label)})">
118
+ {selectedOption.label.charAt(0).toUpperCase()}
119
+ </span>
120
+ {/if}
96
121
  <span class="truncate">{triggerLabel}</span>
97
122
  </span>
98
123
  <svg class="sd-chevron" class:sd-chevron-open={open} fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
@@ -123,7 +148,7 @@
123
148
  autocomplete="off"
124
149
  />
125
150
  {#if searchQuery}
126
- <button type="button" onclick={() => { searchQuery = ''; searchInput?.focus(); }} class="sd-search-clear">
151
+ <button type="button" onclick={() => { searchQuery = ''; searchInput?.focus(); }} class="sd-search-clear" aria-label="Clear search">
127
152
  <svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
128
153
  </button>
129
154
  {/if}
@@ -144,8 +169,17 @@
144
169
  onclick={() => select(option.value)}
145
170
  class="sd-option"
146
171
  class:sd-option-selected={value === option.value}
172
+ style={colorFn && colorFn(option.value) ? `border-left-color: ${colorFn(option.value)}; color: ${colorFn(option.value)};` : ''}
147
173
  >
148
- {#if option.icon}<span class="sd-option-icon">{option.icon}</span>{/if}
174
+ {#if option.image}
175
+ <img src={option.image} alt="" class="sd-avatar" />
176
+ {:else if option.icon}
177
+ <span class="sd-option-icon">{option.icon}</span>
178
+ {:else if option.value}
179
+ <span class="sd-avatar sd-avatar-initials" style="background:oklch(0.55 0.15 {nameHue(option.label)})">
180
+ {option.label.charAt(0).toUpperCase()}
181
+ </span>
182
+ {/if}
149
183
  <span class="truncate">{option.label}</span>
150
184
  {#if value === option.value}
151
185
  <svg class="sd-check" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /></svg>
@@ -158,6 +192,11 @@
158
192
  <li class="sd-empty">No matches for "{searchQuery}"</li>
159
193
  {/if}
160
194
  </ul>
195
+ {#if footer}
196
+ <div class="sd-footer">
197
+ {@render footer()}
198
+ </div>
199
+ {/if}
161
200
  </div>
162
201
  {/if}
163
202
  </div>
@@ -213,6 +252,22 @@
213
252
  flex-shrink: 0;
214
253
  font-size: 0.75rem;
215
254
  }
255
+ .sd-avatar {
256
+ width: 1.25rem;
257
+ height: 1.25rem;
258
+ border-radius: 50%;
259
+ object-fit: cover;
260
+ flex-shrink: 0;
261
+ }
262
+ .sd-avatar-initials {
263
+ display: inline-flex;
264
+ align-items: center;
265
+ justify-content: center;
266
+ font-size: 0.6rem;
267
+ font-weight: 700;
268
+ color: #fff;
269
+ line-height: 1;
270
+ }
216
271
 
217
272
  .sd-chevron {
218
273
  width: 0.75rem;
@@ -339,6 +394,10 @@
339
394
  color: var(--sd-success);
340
395
  }
341
396
 
397
+ .sd-footer {
398
+ border-top: 1px solid var(--sd-border);
399
+ }
400
+
342
401
  .sd-empty {
343
402
  padding: 0.75rem;
344
403
  text-align: center;
@@ -0,0 +1,34 @@
1
+ <!--
2
+ SidebarUserFooter — avatar + name (links to account) + sign out button.
3
+ Placed at the bottom of app sidebar drawers (admin, facilitator, portal).
4
+ Used in JST template layouts.
5
+ -->
6
+ <script lang="ts">
7
+ import UserAvatar from './UserAvatar.svelte'
8
+
9
+ interface Props {
10
+ profile?: { full_name?: string | null; avatar_url?: string | null } | null
11
+ user?: { email?: string | null } | null
12
+ /** Where the avatar/name links to. Defaults to /account */
13
+ accountHref?: string
14
+ /** Sign out URL. Defaults to /account/sign_out */
15
+ signOutHref?: string
16
+ }
17
+
18
+ let {
19
+ profile,
20
+ user,
21
+ accountHref = '/account',
22
+ signOutHref = '/account/sign_out',
23
+ }: Props = $props()
24
+
25
+ let displayName = $derived(profile?.full_name || user?.email || '?')
26
+ </script>
27
+
28
+ <div class="flex items-center gap-2 px-2 py-1">
29
+ <a href={accountHref} class="flex items-center gap-2 flex-1 min-w-0" title="Account settings">
30
+ <UserAvatar name={profile?.full_name} email={user?.email} avatarUrl={profile?.avatar_url} size="sm" />
31
+ <span class="truncate text-sm">{displayName}</span>
32
+ </a>
33
+ <a href={signOutHref} class="btn btn-ghost btn-xs shrink-0">Sign out</a>
34
+ </div>
@@ -136,12 +136,12 @@
136
136
  <div class="signature-pad-container">
137
137
  {#if label}
138
138
  <div class="flex justify-between items-center mb-2">
139
- <label class="text-sm text-base-content/70">
139
+ <span class="text-sm text-base-content/70">
140
140
  {label}
141
141
  {#if required}
142
142
  <span class="text-error">*</span>
143
143
  {/if}
144
- </label>
144
+ </span>
145
145
  <button type="button" onclick={() => clear()} class="btn btn-ghost btn-xs">
146
146
  Clear
147
147
  </button>
@@ -102,7 +102,8 @@
102
102
 
103
103
  <div class="flex items-center gap-1">
104
104
  <!-- Sort dropdown -->
105
- <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
105
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
106
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
106
107
  <div class="dropdown dropdown-end flex-shrink-0" onclick={(e) => e.stopPropagation()}>
107
108
  <button
108
109
  tabindex="0"
@@ -134,7 +135,8 @@
134
135
 
135
136
  <!-- Filter input (optional) -->
136
137
  {#if showFilter}
137
- <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
138
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
139
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
138
140
  <div class="flex-shrink-0" onclick={(e) => e.stopPropagation()}>
139
141
  <input
140
142
  type="text"
@@ -233,6 +233,7 @@
233
233
 
234
234
  {#if text && text.trim()}
235
235
  <div class="flex items-center gap-1 justify-end">
236
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
236
237
  <div
237
238
  class="relative"
238
239
  onmouseenter={() => (showControls = true)}
@@ -96,12 +96,12 @@
96
96
  {#if isEditing}
97
97
  <div class="space-y-4">
98
98
  <div>
99
- <label class="label"><span class="label-text font-medium">Name</span></label>
100
- <input type="text" bind:value={editName} class="input input-bordered w-full" maxlength="80" />
99
+ <label for="channel-info-name" class="label"><span class="label-text font-medium">Name</span></label>
100
+ <input id="channel-info-name" type="text" bind:value={editName} class="input input-bordered w-full" maxlength="80" />
101
101
  </div>
102
102
  <div>
103
- <label class="label"><span class="label-text font-medium">Description</span></label>
104
- <textarea bind:value={editDescription} class="textarea textarea-bordered w-full" rows="3" maxlength="250"></textarea>
103
+ <label for="channel-info-description" class="label"><span class="label-text font-medium">Description</span></label>
104
+ <textarea id="channel-info-description" bind:value={editDescription} class="textarea textarea-bordered w-full" rows="3" maxlength="250"></textarea>
105
105
  </div>
106
106
  <div class="flex gap-2">
107
107
  <button class="btn btn-primary btn-sm" onclick={saveEdit} disabled={saving || !editName.trim()}>
@@ -126,20 +126,20 @@
126
126
  {selectedEmoji}
127
127
  </button>
128
128
  <div class="flex-1">
129
- <label class="label"><span class="label-text font-medium">Channel Name</span></label>
130
- <input type="text" bind:value={channelName} class="input input-bordered w-full" placeholder="e.g., general, announcements" maxlength="80" />
129
+ <label for="create-channel-name" class="label"><span class="label-text font-medium">Channel Name</span></label>
130
+ <input id="create-channel-name" type="text" bind:value={channelName} class="input input-bordered w-full" placeholder="e.g., general, announcements" maxlength="80" />
131
131
  </div>
132
132
  </div>
133
133
 
134
134
  <!-- Description -->
135
135
  <div>
136
- <label class="label"><span class="label-text font-medium">Description</span></label>
137
- <textarea bind:value={description} class="textarea textarea-bordered w-full" placeholder="What's this channel about?" rows="2" maxlength="250"></textarea>
136
+ <label for="create-channel-description" class="label"><span class="label-text font-medium">Description</span></label>
137
+ <textarea id="create-channel-description" bind:value={description} class="textarea textarea-bordered w-full" placeholder="What's this channel about?" rows="2" maxlength="250"></textarea>
138
138
  </div>
139
139
 
140
140
  <!-- Channel type -->
141
141
  <div>
142
- <label class="label"><span class="label-text font-medium">Channel Type</span></label>
142
+ <span class="label"><span class="label-text font-medium">Channel Type</span></span>
143
143
  <div class="flex gap-3">
144
144
  <label class="cursor-pointer flex items-center gap-2">
145
145
  <input type="radio" name="channelType" class="radio radio-primary" bind:group={channelType} value="public" />
@@ -154,8 +154,8 @@
154
154
 
155
155
  <!-- Member selection -->
156
156
  <div>
157
- <label class="label"><span class="label-text font-medium">Add Members ({selectedMembers.length})</span></label>
158
- <input type="text" bind:value={searchQuery} class="input input-bordered input-sm w-full mb-2" placeholder="Search members..." />
157
+ <label for="create-channel-member-search" class="label"><span class="label-text font-medium">Add Members ({selectedMembers.length})</span></label>
158
+ <input id="create-channel-member-search" type="text" bind:value={searchQuery} class="input input-bordered input-sm w-full mb-2" placeholder="Search members..." />
159
159
  <div class="max-h-40 overflow-y-auto space-y-1">
160
160
  {#each filteredMembers as member}
161
161
  <button type="button" class="w-full flex items-center gap-3 px-2 py-1.5 rounded hover:bg-base-200 transition-colors" onclick={() => toggleMember(member.id)}>
@@ -150,6 +150,7 @@
150
150
  const canDelete = $derived(showDelete && currentUserId === attachment.uploaded_by)
151
151
  </script>
152
152
 
153
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
153
154
  <div class="relative group {className}" oncontextmenu={handleContextMenu}>
154
155
  {#if loading}
155
156
  <div class="flex items-center justify-center bg-base-200 rounded-lg {compact ? 'w-16 h-16' : 'w-48 h-32'}">
@@ -203,7 +204,9 @@
203
204
 
204
205
  <!-- Context Menu -->
205
206
  {#if showContextMenu}
206
- <div class="fixed z-50 bg-base-100 rounded-lg shadow-xl border border-base-300 py-1 min-w-[160px]" style="left: {contextMenuPosition.x}px; top: {contextMenuPosition.y}px;" onclick={(e) => e.stopPropagation()} role="menu">
207
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
208
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
209
+ <div class="fixed z-50 bg-base-100 rounded-lg shadow-xl border border-base-300 py-1 min-w-[160px]" style="left: {contextMenuPosition.x}px; top: {contextMenuPosition.y}px;" onclick={(e) => e.stopPropagation()} role="menu" tabindex="-1">
207
210
  <button class="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-base-200 transition-colors" onclick={handleOpenInNewTab} role="menuitem">↗ Open in new tab</button>
208
211
  <button class="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-base-200 transition-colors" onclick={handleDownload} role="menuitem">⬇ Download</button>
209
212
  <button class="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-base-200 transition-colors" onclick={handleCopyLink} role="menuitem">📋 Copy link</button>
@@ -334,6 +334,7 @@
334
334
 
335
335
  <input bind:this={fileInputRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp,video/mp4,video/webm,video/quicktime,application/pdf" multiple class="hidden" onchange={handleFileSelect} {disabled} />
336
336
 
337
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
337
338
  <div class="flex flex-col gap-2 relative" ondragenter={handleDragEnter} ondragleave={handleDragLeave} ondragover={handleDragOver} ondrop={handleDrop}>
338
339
  {#if isDragging}
339
340
  <div class="absolute inset-0 z-50 bg-primary/10 border-2 border-dashed border-primary rounded-lg flex items-center justify-center pointer-events-none">
@@ -165,12 +165,12 @@
165
165
  {#if globalSettings.quiet_hours_enabled}
166
166
  <div class="flex gap-3">
167
167
  <div class="flex-1">
168
- <label class="label"><span class="label-text text-xs">Start</span></label>
169
- <input type="time" bind:value={globalSettings.quiet_hours_start} class="input input-bordered input-sm w-full" />
168
+ <label for="notif-quiet-hours-start" class="label"><span class="label-text text-xs">Start</span></label>
169
+ <input id="notif-quiet-hours-start" type="time" bind:value={globalSettings.quiet_hours_start} class="input input-bordered input-sm w-full" />
170
170
  </div>
171
171
  <div class="flex-1">
172
- <label class="label"><span class="label-text text-xs">End</span></label>
173
- <input type="time" bind:value={globalSettings.quiet_hours_end} class="input input-bordered input-sm w-full" />
172
+ <label for="notif-quiet-hours-end" class="label"><span class="label-text text-xs">End</span></label>
173
+ <input id="notif-quiet-hours-end" type="time" bind:value={globalSettings.quiet_hours_end} class="input input-bordered input-sm w-full" />
174
174
  </div>
175
175
  </div>
176
176
  {/if}
@@ -197,8 +197,8 @@
197
197
  </label>
198
198
  </div>
199
199
  <div>
200
- <label class="label"><span class="label-text font-medium">Notification Level</span></label>
201
- <select bind:value={channelPrefs.notification_level} class="select select-bordered w-full">
200
+ <label for="notif-channel-level" class="label"><span class="label-text font-medium">Notification Level</span></label>
201
+ <select id="notif-channel-level" bind:value={channelPrefs.notification_level} class="select select-bordered w-full">
202
202
  <option value="all">All Messages</option>
203
203
  <option value="mentions">Mentions Only</option>
204
204
  <option value="none">None</option>
package/src/lib/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  // Components — Universal
2
2
  export { default as UserAvatar } from './components/UserAvatar.svelte';
3
+ export { default as SidebarUserFooter } from './components/SidebarUserFooter.svelte';
3
4
 
4
5
  // Components — from JAT IDE
5
6
  export { default as SearchDropdown } from './components/SearchDropdown.svelte';
@@ -9,6 +10,7 @@ export { default as ResizableDivider } from './components/ResizableDivider.svelt
9
10
  export { default as DateRangePicker } from './components/DateRangePicker.svelte';
10
11
  export { default as SortDropdown } from './components/SortDropdown.svelte';
11
12
  export { default as InlineEdit } from './components/InlineEdit.svelte';
13
+ export { default as FloatingActionBar } from './components/FloatingActionBar.svelte';
12
14
 
13
15
  // Components — from Flush
14
16
  export { default as Button } from './components/Button.svelte';
@@ -39,6 +41,14 @@ export { default as SpeechForm } from './components/SpeechForm.svelte';
39
41
  export { default as ThemeSelector } from './components/ThemeSelector.svelte';
40
42
  export { default as Sparkline } from './components/Sparkline.svelte';
41
43
 
44
+ // Components — from JST
45
+ export { default as AvatarUpload } from './components/AvatarUpload.svelte';
46
+ export { default as PdfThumbnail } from './components/PdfThumbnail.svelte';
47
+ export { default as FilePicker } from './components/FilePicker.svelte';
48
+
49
+ // FilePicker types
50
+ export type { FilePickerFile, FilePickerSelection } from './types/filePicker';
51
+
42
52
  // Components — Emoji
43
53
  export { default as EmojiPicker } from './components/EmojiPicker.svelte';
44
54
 
@@ -51,6 +61,8 @@ export { default as LinkShortener } from './components/LinkShortener.svelte';
51
61
 
52
62
  // Components — from Marduk
53
63
  export { default as SignaturePad } from './components/SignaturePad.svelte';
64
+ export { default as MilestoneCard } from './components/MilestoneCard.svelte';
65
+ export { default as MilestoneTimeline } from './components/MilestoneTimeline.svelte';
54
66
  export { default as TimeSlotPicker } from './components/TimeSlotPicker.svelte';
55
67
  export { default as CalendarPicker } from './components/CalendarPicker.svelte';
56
68
  export { default as AvailabilityModal } from './components/AvailabilityModal.svelte';
@@ -64,11 +76,15 @@ export type { SearchDropdownOption, SearchDropdownGroup } from './components/Sea
64
76
  export type { ChipSuggestion, SuggestionGroup, ChipInfo } from './components/ChipInput.svelte';
65
77
  export type { SortOption } from './components/SortDropdown.svelte';
66
78
  export type { DisplaySegment } from './components/InlineEdit.svelte';
79
+ export type { ActionButton, ActionDropdown, DropdownItem } from './components/FloatingActionBar.svelte';
67
80
 
68
81
  // Component types — Flush
69
82
  export type { SelectOption } from './components/SelectInput.svelte';
70
83
  export type { StatusValue } from './components/StatusBadge.svelte';
71
84
 
85
+ // Milestone types (from JST contract/billing)
86
+ export type { Milestone, MilestoneStatus } from './types/milestone';
87
+
72
88
  // Booking types (centralized)
73
89
  export type {
74
90
  LocationType,
@@ -102,8 +118,17 @@ export {
102
118
  formatDate,
103
119
  getTimeSinceMs,
104
120
  getTimeSinceMinutes,
105
- isWithinMinutes
121
+ isWithinMinutes,
122
+ formatDateTime,
123
+ taskAge,
106
124
  } from './utils/dateFormatters';
125
+ // Utilities — task display helpers
126
+ export {
127
+ statusColor,
128
+ priorityColor,
129
+ typeIcon,
130
+ statusLabel,
131
+ } from './utils/taskUtils';
107
132
 
108
133
  // Utilities — phone formatting
109
134
  export {
@@ -0,0 +1,14 @@
1
+ export interface FilePickerFile {
2
+ id: string
3
+ name: string
4
+ created_at: string
5
+ updated_at: string
6
+ metadata: Record<string, any>
7
+ scope: "personal" | "team"
8
+ }
9
+
10
+ export interface FilePickerSelection {
11
+ file: FilePickerFile
12
+ url: string
13
+ downloadUrl: string
14
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Generic milestone/timeline types for stepped workflows.
3
+ *
4
+ * Used by MilestoneCard and MilestoneTimeline components.
5
+ * Projects extend this with domain-specific fields (e.g. stripe_invoice_id).
6
+ */
7
+
8
+ export type MilestoneStatus = "pending" | "delivered" | "accepted" | "paid"
9
+
10
+ export interface Milestone {
11
+ id: string
12
+ name: string
13
+ description: string
14
+ percentage: number
15
+ amount: number
16
+ acceptance_criteria: string
17
+ status: MilestoneStatus
18
+ sort_order: number
19
+ delivered_at: string | null
20
+ accepted_at: string | null
21
+ paid_at: string | null
22
+ }
@@ -151,3 +151,44 @@ export function getTimeSinceMinutes(timestamp: string | null | undefined): numbe
151
151
  export function isWithinMinutes(timestamp: string | null | undefined, minutes: number): boolean {
152
152
  return getTimeSinceMinutes(timestamp) < minutes;
153
153
  }
154
+
155
+ /**
156
+ * Format date + time for detail views (e.g. "Jan 5, 2:30 PM").
157
+ * No year — use formatFullDate when year is needed.
158
+ */
159
+ export function formatDateTime(dateStr: string | null | undefined): string {
160
+ if (!dateStr) return '';
161
+ const date = parseTimestamp(dateStr);
162
+ if (!date) return '';
163
+ return date.toLocaleString(undefined, {
164
+ month: 'short',
165
+ day: 'numeric',
166
+ hour: 'numeric',
167
+ minute: '2-digit',
168
+ });
169
+ }
170
+
171
+ /**
172
+ * Color-coded relative age for task rows.
173
+ * Returns a label (e.g. "3d", "2w") and an oklch color string
174
+ * that shifts from green (fresh) → blue → amber → orange → red (stale).
175
+ *
176
+ * Usage:
177
+ * const age = taskAge(task.created_at)
178
+ * // <span style="color: {age.color}">{age.label}</span>
179
+ */
180
+ export function taskAge(dateStr: string): { label: string; color: string } {
181
+ const now = Date.now();
182
+ const created = new Date(dateStr).getTime();
183
+ const minutes = Math.floor((now - created) / 60000);
184
+ const hours = Math.floor(minutes / 60);
185
+ const days = Math.floor(hours / 24);
186
+ const weeks = Math.floor(days / 7);
187
+ const months = Math.floor(days / 30);
188
+ if (minutes < 60) return { label: `${minutes}m`, color: 'oklch(0.75 0.18 145)' }; // green — fresh
189
+ if (hours < 24) return { label: `${hours}h`, color: 'oklch(0.75 0.18 145)' }; // green
190
+ if (days < 3) return { label: `${days}d`, color: 'oklch(0.70 0.15 200)' }; // blue — recent
191
+ if (days < 7) return { label: `${days}d`, color: 'oklch(0.75 0.15 85)' }; // amber — aging
192
+ if (weeks < 4) return { label: `${weeks}w`, color: 'oklch(0.65 0.18 50)' }; // orange — stale
193
+ return { label: `${months}mo`, color: 'oklch(0.65 0.20 25)' }; // red — old
194
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Shared helpers for project_tasks UI.
3
+ * Used across all JST-based projects on tasks/feedback pages.
4
+ */
5
+
6
+ /**
7
+ * DaisyUI badge class for a task status value.
8
+ * Usage: <span class="badge {statusColor(task.status)}">...</span>
9
+ */
10
+ export function statusColor(status: string): string {
11
+ switch (status) {
12
+ case 'open': return 'badge-ghost';
13
+ case 'in_progress': return 'badge-warning';
14
+ case 'waiting': return 'badge-warning badge-outline';
15
+ case 'blocked': return 'badge-error badge-outline';
16
+ case 'submitted': return 'badge-info';
17
+ case 'accepted': return 'badge-success';
18
+ case 'deployed': return 'badge-success badge-outline';
19
+ case 'closed': return 'badge-neutral';
20
+ case 'dev': return 'badge-ghost badge-outline';
21
+ default: return 'badge-ghost';
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Tailwind text class for a task priority value.
27
+ * Usage: <span class={priorityColor(task.priority)}>...</span>
28
+ */
29
+ export function priorityColor(priority: string | null | undefined): string {
30
+ switch (priority) {
31
+ case 'critical': return 'text-error font-bold';
32
+ case 'high': return 'text-warning font-semibold';
33
+ case 'medium': return 'text-info';
34
+ case 'low': return 'text-base-content/60';
35
+ default: return '';
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Emoji icon for a task issue_type value.
41
+ * Usage: {typeIcon(task.issue_type)} {task.title}
42
+ */
43
+ export function typeIcon(type: string): string {
44
+ switch (type) {
45
+ case 'bug': return '🐛';
46
+ case 'feature': return '✨';
47
+ case 'task': return '📋';
48
+ case 'epic': return '🏔️';
49
+ default: return '📄';
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Human-readable label for a task status value.
55
+ * "in_progress" → "In Progress"
56
+ */
57
+ export function statusLabel(status: string): string {
58
+ return status.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
59
+ }