hub-shell-ia.mjs
287 lines 8.3 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago
1 /**
2 * Hub signed-in shell IA helpers (HUB-DASH-IA-b + c + d).
3 * Pure functions for Review labels, History segments, Needs-you, badge count,
4 * Review inbox polish, Vault search disclosure, Insights chrome, and mobile nav.
5 */
6
7 export const HUB_HISTORY_SEGMENT_KEY = 'hub_history_segment';
8 export const HUB_NEEDS_YOU_DISMISS_KEY = 'hub_needs_you_dismissed';
9
10 /** User-facing glossary for Hub chrome (internal data-tab values stay unchanged). */
11 export const HUB_SHELL_LABELS = Object.freeze({
12 notes: 'Vault',
13 suggested: 'Review',
14 activity: 'Activity',
15 problem: 'Discarded',
16 history: 'History',
17 insights: 'Insights',
18 });
19
20 /**
21 * Map internal main-tab name to user-facing label.
22 * @param {string} tabName
23 * @returns {string}
24 */
25 export function hubShellLabelForTab(tabName) {
26 const key = String(tabName || '');
27 if (Object.prototype.hasOwnProperty.call(HUB_SHELL_LABELS, key)) {
28 return HUB_SHELL_LABELS[key];
29 }
30 return key;
31 }
32
33 /**
34 * Normalize History segment to activity | problem (default Activity).
35 * @param {unknown} raw
36 * @returns {'activity'|'problem'}
37 */
38 export function normalizeHistorySegment(raw) {
39 return raw === 'problem' ? 'problem' : 'activity';
40 }
41
42 /**
43 * Read last History segment from localStorage (default Activity).
44 * @param {{ getItem?: (k: string) => string|null }} [store]
45 * @returns {'activity'|'problem'}
46 */
47 export function readHistorySegment(store) {
48 const s = store && typeof store.getItem === 'function' ? store : null;
49 try {
50 return normalizeHistorySegment(s ? s.getItem(HUB_HISTORY_SEGMENT_KEY) : null);
51 } catch (_) {
52 return 'activity';
53 }
54 }
55
56 /**
57 * Persist History segment for next visit.
58 * @param {'activity'|'problem'|string} segment
59 * @param {{ setItem?: (k: string, v: string) => void }} [store]
60 */
61 export function writeHistorySegment(segment, store) {
62 const s = store && typeof store.setItem === 'function' ? store : null;
63 if (!s) return;
64 try {
65 s.setItem(HUB_HISTORY_SEGMENT_KEY, normalizeHistorySegment(segment));
66 } catch (_) {
67 /* ignore quota / private mode */
68 }
69 }
70
71 /**
72 * Unfiltered proposed count for rail/header badges (not filtered list length).
73 * @param {unknown} count
74 * @returns {number} integer in 0..100 (API list limit for Hub badge)
75 */
76 export function clampProposedBadgeCount(count) {
77 const n = typeof count === 'number' ? count : Number(count);
78 if (!Number.isFinite(n) || n <= 0) return 0;
79 return Math.min(100, Math.floor(n));
80 }
81
82 /**
83 * Display string for badge; empty when zero (caller hides element).
84 * @param {unknown} count
85 * @returns {string}
86 */
87 export function formatProposedBadgeText(count) {
88 const n = clampProposedBadgeCount(count);
89 return n > 0 ? String(n) : '';
90 }
91
92 /**
93 * Whether Needs-you banner should show on Vault home.
94 * @param {number} proposedCount
95 * @param {boolean} dismissedForSession
96 * @returns {boolean}
97 */
98 export function shouldShowNeedsYouBanner(proposedCount, dismissedForSession) {
99 return clampProposedBadgeCount(proposedCount) > 0 && !dismissedForSession;
100 }
101
102 /**
103 * Copy for Needs-you banner.
104 * @param {number} proposedCount
105 * @returns {string}
106 */
107 export function needsYouBannerCopy(proposedCount) {
108 const n = clampProposedBadgeCount(proposedCount);
109 const noun = n === 1 ? 'proposal' : 'proposals';
110 return n + ' ' + noun + ' waiting in Review';
111 }
112
113 /**
114 * Whether badge should pulse once (count increased).
115 * @param {number} prevCount
116 * @param {number} nextCount
117 * @returns {boolean}
118 */
119 export function shouldPulseReviewBadge(prevCount, nextCount) {
120 const prev = clampProposedBadgeCount(prevCount);
121 const next = clampProposedBadgeCount(nextCount);
122 return next > prev;
123 }
124
125 /**
126 * Primary rail order for static contracts.
127 * @returns {string[]}
128 */
129 export function hubPrimaryRailOrder() {
130 return ['Vault', 'Review', 'History'];
131 }
132
133 /**
134 * Mobile bottom nav primary slots (expert item 20).
135 * @returns {string[]}
136 */
137 export function hubMobileBottomNavOrder() {
138 return ['Vault', 'Review', 'History', 'More'];
139 }
140
141 /**
142 * Mobile More sheet actions (expert item 20). Insights is relocated from the
143 * secondary rail so the entry point survives when the left rail is hidden.
144 * @returns {string[]}
145 */
146 export function hubMobileMoreActions() {
147 return ['Insights', 'Import', 'Connect', 'Settings', 'Help'];
148 }
149
150 /**
151 * Critical data-tab values that must remain stable.
152 * @returns {string[]}
153 */
154 export function hubCriticalDataTabs() {
155 return ['notes', 'suggested', 'activity', 'problem'];
156 }
157
158 /**
159 * Resolve notes browse sub-view (list | calendar | graph/Insights).
160 * @param {unknown} view
161 * @returns {'list'|'calendar'|'graph'}
162 */
163 export function normalizeNotesView(view) {
164 if (view === 'calendar' || view === 'graph') return view;
165 return 'list';
166 }
167
168 /**
169 * Chrome visibility for Vault search vs Review toolbar vs Insights.
170 * Review mode hides note search; Vault list/calendar shows search + browse;
171 * Insights (graph) shows neither note search nor proposal filters.
172 * @param {string} activeTab notes|suggested|activity|problem
173 * @param {string} [notesView] list|calendar|graph
174 * @returns {{ noteSearch: boolean, browseToolbar: boolean, proposalFilters: boolean, insights: boolean }}
175 */
176 export function hubChromeVisibility(activeTab, notesView) {
177 const tab = String(activeTab || 'notes');
178 const view = normalizeNotesView(notesView);
179 const isReviewLike = tab === 'suggested' || tab === 'activity' || tab === 'problem';
180 const isInsights = tab === 'notes' && view === 'graph';
181 const isVaultBrowse = tab === 'notes' && !isInsights;
182 return {
183 noteSearch: isVaultBrowse,
184 browseToolbar: isVaultBrowse,
185 proposalFilters: isReviewLike,
186 insights: isInsights,
187 };
188 }
189
190 /**
191 * Relative time for Review row density (compact, English).
192 * @param {unknown} iso
193 * @param {number} [nowMs]
194 * @returns {string}
195 */
196 export function formatRelativeTime(iso, nowMs) {
197 const now = typeof nowMs === 'number' && Number.isFinite(nowMs) ? nowMs : Date.now();
198 if (iso == null || iso === '') return '';
199 const t = Date.parse(String(iso));
200 if (!Number.isFinite(t)) return '';
201 let sec = Math.round((now - t) / 1000);
202 if (sec < 0) sec = 0;
203 if (sec < 45) return 'just now';
204 if (sec < 90) return '1m ago';
205 const min = Math.round(sec / 60);
206 if (min < 60) return min + 'm ago';
207 const hr = Math.round(min / 60);
208 if (hr < 24) return hr + 'h ago';
209 const day = Math.round(hr / 24);
210 if (day < 30) return day + 'd ago';
211 const mo = Math.round(day / 30);
212 if (mo < 12) return mo + 'mo ago';
213 const yr = Math.round(day / 365);
214 return yr + 'y ago';
215 }
216
217 /**
218 * Whether a Review row should show the pending-eval chip.
219 * @param {unknown} evaluationStatus
220 * @returns {boolean}
221 */
222 export function reviewRowNeedsPendingEvalChip(evaluationStatus) {
223 return String(evaluationStatus || '').trim().toLowerCase() === 'pending';
224 }
225
226 /**
227 * Split-view position label ("N of M").
228 * @param {number} index1Based
229 * @param {number} total
230 * @returns {string}
231 */
232 export function formatReviewSplitPosition(index1Based, total) {
233 const i = Math.floor(Number(index1Based));
234 const m = Math.floor(Number(total));
235 if (!Number.isFinite(i) || !Number.isFinite(m) || i < 1 || m < 1) return '';
236 const n = Math.min(i, m);
237 return n + ' of ' + m;
238 }
239
240 /**
241 * Advanced filters stay collapsed unless already open or filters are active.
242 * @param {boolean} hasActiveFilters
243 * @param {boolean} [userOpened]
244 * @returns {boolean}
245 */
246 export function shouldExpandVaultAdvancedFilters(hasActiveFilters, userOpened) {
247 return Boolean(hasActiveFilters) || Boolean(userOpened);
248 }
249
250 /**
251 * Empty Review primary CTA label (expert item 17).
252 * @returns {string}
253 */
254 export function emptyReviewPrimaryCtaLabel() {
255 return 'New proposal';
256 }
257
258 /**
259 * Empty Review secondary CTA label (expert item 17).
260 * @returns {string}
261 */
262 export function emptyReviewSecondaryCtaLabel() {
263 return 'How Review works';
264 }
265
266 /**
267 * Show pending-eval one-click chip when policy requires evaluation.
268 * @param {unknown} evaluationRequired
269 * @returns {boolean}
270 */
271 export function shouldShowPendingEvalQuickChip(evaluationRequired) {
272 return Boolean(evaluationRequired);
273 }
274
275 /**
276 * Clamp list keyboard index into [0, length-1] (empty → 0).
277 * @param {number} index
278 * @param {number} length
279 * @returns {number}
280 */
281 export function clampListKeyboardIndex(index, length) {
282 const len = Math.floor(Number(length));
283 if (!Number.isFinite(len) || len <= 0) return 0;
284 const i = Math.floor(Number(index));
285 if (!Number.isFinite(i) || i < 0) return 0;
286 return Math.min(i, len - 1);
287 }
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago