@profpowell/vb-project-planning 0.2.1 → 0.2.3
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.
- package/README.md +10 -5
- package/dist/vb-project-planning.css +69 -3
- package/dist/vb-project-planning.js +3191 -2544
- package/dist/vb-project-planning.min.css +1 -1
- package/dist/vb-project-planning.min.js +221 -197
- package/package.json +7 -4
- package/src/lib/board-surface.js +158 -0
- package/src/lib/bundle-registry.js +2 -75
- package/src/lib/dates.js +125 -0
- package/src/lib/escape.js +34 -0
- package/src/lib/hash.js +16 -0
- package/src/lib/portrait-url.js +3 -29
- package/src/lib/src-loader.js +78 -0
- package/src/lib/vb-view-transition.js +7 -1
- package/src/utils/copy-init.js +56 -17
- package/src/web-components/_ux-base.js +409 -24
- package/src/web-components/adr-wc/README.md +18 -16
- package/src/web-components/adr-wc/api.json +7 -5
- package/src/web-components/adr-wc/logic.js +50 -136
- package/src/web-components/burndown-chart/api.json +8 -6
- package/src/web-components/burndown-chart/logic.js +75 -39
- package/src/web-components/empathy-map/api.json +13 -13
- package/src/web-components/empathy-map/logic.js +161 -168
- package/src/web-components/empathy-map/static.html +3 -3
- package/src/web-components/empathy-map/styles.js +1 -0
- package/src/web-components/gantt-chart/README.md +12 -6
- package/src/web-components/gantt-chart/api.json +23 -7
- package/src/web-components/gantt-chart/logic.js +456 -431
- package/src/web-components/gantt-chart/styles.css +14 -1
- package/src/web-components/impact-effort/api.json +9 -7
- package/src/web-components/impact-effort/logic.js +238 -209
- package/src/web-components/impact-effort/static.html +12 -4
- package/src/web-components/iron-triangle/_capacity.js +0 -18
- package/src/web-components/kanban-board/api.json +8 -6
- package/src/web-components/kanban-board/logic.js +289 -333
- package/src/web-components/product-roadmap/api.json +14 -13
- package/src/web-components/product-roadmap/logic.js +393 -246
- package/src/web-components/product-roadmap/styles.css +40 -0
- package/src/web-components/quadrant-grid/api.json +9 -4
- package/src/web-components/quadrant-grid/logic.js +80 -12
- package/src/web-components/quadrant-grid/styles.css +7 -0
- package/src/web-components/quality-target/_quality-utils.js +2 -46
- package/src/web-components/review-surface/README.md +9 -5
- package/src/web-components/review-surface/api.json +10 -8
- package/src/web-components/review-surface/logic.js +349 -218
- package/src/web-components/risk-register/api.json +1 -6
- package/src/web-components/risk-register/logic.js +71 -24
- package/src/web-components/risk-register/styles.css +9 -1
- package/src/web-components/story-map/api.json +8 -6
- package/src/web-components/story-map/logic.js +280 -281
- package/src/web-components/story-map/static.html +15 -5
- package/src/web-components/traceability-matrix/api.json +10 -3
- package/src/web-components/traceability-matrix/logic.js +112 -63
- package/src/web-components/traceability-matrix/styles.css +22 -1
- package/src/web-components/user-journey/api.json +25 -24
- package/src/web-components/user-journey/logic.js +76 -127
- package/src/web-components/user-journey/static.html +2 -2
- package/src/web-components/user-persona/api.json +15 -14
- package/src/web-components/user-persona/logic.js +71 -117
- package/src/web-components/user-persona/styles.js +1 -0
- package/src/web-components/user-story/api.json +13 -11
- package/src/web-components/user-story/logic.js +164 -195
- package/src/web-components/user-story/styles.js +28 -1
- package/src/web-components/work-item/README.md +3 -3
- package/src/web-components/work-item/api.json +13 -11
- package/src/web-components/work-item/logic.js +58 -154
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profpowell/vb-project-planning",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Project- and UX-planning Web Components for Vanilla Breeze: personas, stories, journeys, empathy maps, impact/effort and quadrant grids, risk and traceability matrices, burndown, roadmap, kanban, story map, gantt, review surface, work items, ADRs, iron triangle, capacity plans, quality targets, and requirement cards.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Thomas A. Powell",
|
|
@@ -63,8 +63,10 @@
|
|
|
63
63
|
"build:js": "esbuild src/index.js --bundle --format=esm --outfile=dist/vb-project-planning.js && esbuild src/index.js --bundle --format=esm --minify --outfile=dist/vb-project-planning.min.js",
|
|
64
64
|
"build:css": "esbuild src/index.css --bundle --outfile=dist/vb-project-planning.css && esbuild src/index.css --bundle --minify --outfile=dist/vb-project-planning.min.css",
|
|
65
65
|
"prepublishOnly": "npm run build",
|
|
66
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
67
|
+
"test:unit": "TZ=America/Los_Angeles node --test 'tests/unit/*.test.mjs'",
|
|
66
68
|
"test:components": "playwright test",
|
|
67
|
-
"test": "playwright test"
|
|
69
|
+
"test": "npm run test:unit && playwright test"
|
|
68
70
|
},
|
|
69
71
|
"peerDependencies": {
|
|
70
72
|
"vanilla-breeze": ">=0.3.0"
|
|
@@ -75,7 +77,8 @@
|
|
|
75
77
|
}
|
|
76
78
|
},
|
|
77
79
|
"devDependencies": {
|
|
78
|
-
"@playwright/test": "^1.
|
|
79
|
-
"esbuild": "^0.
|
|
80
|
+
"@playwright/test": "^1.64.1",
|
|
81
|
+
"esbuild": "^0.28.2",
|
|
82
|
+
"typescript": "^5.9.3"
|
|
80
83
|
}
|
|
81
84
|
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for the drag-surface "board" components
|
|
3
|
+
* (kanban-board, story-map, impact-effort, quadrant-grid).
|
|
4
|
+
*
|
|
5
|
+
* Small, DOM-only utilities: live-region announcements, human labels for
|
|
6
|
+
* cards, empty-column placeholders, and building a <user-story> card from a
|
|
7
|
+
* JSON record using the slots user-story actually reads.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Convert a hyphenated or lowercase id to Title Case.
|
|
12
|
+
* @param {string} str
|
|
13
|
+
* @returns {string}
|
|
14
|
+
*/
|
|
15
|
+
export function titleCase(str) {
|
|
16
|
+
return String(str ?? '')
|
|
17
|
+
.replace(/[-_]/g, ' ')
|
|
18
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Create a polite, atomic status region for move announcements.
|
|
23
|
+
* @param {string} className
|
|
24
|
+
* @returns {HTMLDivElement}
|
|
25
|
+
*/
|
|
26
|
+
export function createLiveRegion(className) {
|
|
27
|
+
const region = document.createElement('div');
|
|
28
|
+
region.className = className;
|
|
29
|
+
region.setAttribute('role', 'status');
|
|
30
|
+
region.setAttribute('aria-live', 'polite');
|
|
31
|
+
region.setAttribute('aria-atomic', 'true');
|
|
32
|
+
return region;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Announce a message: clear, then set on the next frame so repeated
|
|
37
|
+
* identical messages are re-read.
|
|
38
|
+
* @param {HTMLElement | null} region
|
|
39
|
+
* @param {string} msg
|
|
40
|
+
*/
|
|
41
|
+
export function announce(region, msg) {
|
|
42
|
+
if (!region) return;
|
|
43
|
+
region.textContent = '';
|
|
44
|
+
requestAnimationFrame(() => { region.textContent = msg; });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A human-readable label for a card: its title slot, its action slot, an
|
|
49
|
+
* aria-label, or its visible text. Generated ids are only a last resort.
|
|
50
|
+
* @param {Element | null | undefined} item
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
export function itemLabel(item) {
|
|
54
|
+
if (!item) return 'item';
|
|
55
|
+
const clean = (/** @type {string | null | undefined} */ s) => (s || '').replace(/\s+/g, ' ').trim();
|
|
56
|
+
const slotted = clean(item.querySelector(':scope > [slot="title"]')?.textContent)
|
|
57
|
+
|| clean(item.querySelector(':scope > [slot="action"]')?.textContent);
|
|
58
|
+
if (slotted) return truncate(slotted);
|
|
59
|
+
const aria = clean(item.getAttribute('aria-label'));
|
|
60
|
+
if (aria) return truncate(aria);
|
|
61
|
+
const text = clean(item.textContent);
|
|
62
|
+
if (text) return truncate(text);
|
|
63
|
+
return item.getAttribute('story-id') || item.getAttribute('data-id') || 'item';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** @param {string} s */
|
|
67
|
+
function truncate(s) {
|
|
68
|
+
return s.length > 60 ? `${s.slice(0, 59).trimEnd()}…` : s;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Add or remove the "empty" placeholder so it shows only when the surface
|
|
73
|
+
* holds no draggable cards.
|
|
74
|
+
* @param {Element} surface
|
|
75
|
+
* @param {string} className
|
|
76
|
+
* @param {string} text
|
|
77
|
+
*/
|
|
78
|
+
export function syncPlaceholder(surface, className, text) {
|
|
79
|
+
const hasCards = !!surface.querySelector(':scope > [draggable="true"]');
|
|
80
|
+
const placeholder = surface.querySelector(`:scope > .${className}`);
|
|
81
|
+
if (!hasCards && !placeholder) {
|
|
82
|
+
const p = document.createElement('p');
|
|
83
|
+
p.className = className;
|
|
84
|
+
p.textContent = text;
|
|
85
|
+
surface.appendChild(p);
|
|
86
|
+
} else if (hasCards && placeholder) {
|
|
87
|
+
placeholder.remove();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Does this JSON record describe a user story (rather than a plain card)?
|
|
93
|
+
* @param {Record<string, any>} record
|
|
94
|
+
*/
|
|
95
|
+
export function isStoryRecord(record) {
|
|
96
|
+
return !!(record && (record.persona || record.action || record.benefit || record.storyId));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Build a `<user-story>` from a JSON record. State goes on the attributes
|
|
101
|
+
* user-story observes; text content goes into the slots it reads
|
|
102
|
+
* (persona, action, benefit, title, acceptance-criteria, tasks, notes).
|
|
103
|
+
* Works whether or not user-story has been defined yet.
|
|
104
|
+
*
|
|
105
|
+
* @param {Record<string, any>} record
|
|
106
|
+
* @param {string} [defaultDetail] - detail level when the record has none
|
|
107
|
+
* @returns {HTMLElement}
|
|
108
|
+
*/
|
|
109
|
+
export function createStoryCard(record, defaultDetail) {
|
|
110
|
+
const el = document.createElement('user-story');
|
|
111
|
+
/** @type {Array<[string, string]>} */
|
|
112
|
+
const attrs = [
|
|
113
|
+
['storyId', 'story-id'], ['personaId', 'persona-id'], ['priority', 'priority'],
|
|
114
|
+
['status', 'status'], ['points', 'points'], ['epic', 'epic'],
|
|
115
|
+
];
|
|
116
|
+
for (const [key, attr] of attrs) {
|
|
117
|
+
if (record[key] != null && record[key] !== '') el.setAttribute(attr, String(record[key]));
|
|
118
|
+
}
|
|
119
|
+
const detail = record.detail || defaultDetail;
|
|
120
|
+
if (detail) el.setAttribute('detail', String(detail));
|
|
121
|
+
|
|
122
|
+
for (const slot of ['persona', 'action', 'benefit']) {
|
|
123
|
+
if (record[slot]) el.appendChild(slotted('span', slot, record[slot]));
|
|
124
|
+
}
|
|
125
|
+
if (record.title) el.appendChild(slotted('h3', 'title', record.title));
|
|
126
|
+
for (const [key, slot] of [['acceptanceCriteria', 'acceptance-criteria'], ['tasks', 'tasks'], ['notes', 'notes']]) {
|
|
127
|
+
const value = record[key];
|
|
128
|
+
if (!value) continue;
|
|
129
|
+
if (Array.isArray(value)) {
|
|
130
|
+
const ul = document.createElement('ul');
|
|
131
|
+
ul.slot = slot;
|
|
132
|
+
for (const entry of value) {
|
|
133
|
+
const li = document.createElement('li');
|
|
134
|
+
li.textContent = String(entry);
|
|
135
|
+
ul.appendChild(li);
|
|
136
|
+
}
|
|
137
|
+
el.appendChild(ul);
|
|
138
|
+
} else {
|
|
139
|
+
el.appendChild(slotted('p', slot, value));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return el;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* @param {string} tag
|
|
147
|
+
* @param {string} slot
|
|
148
|
+
* @param {unknown} text
|
|
149
|
+
*/
|
|
150
|
+
function slotted(tag, slot, text) {
|
|
151
|
+
const el = document.createElement(tag);
|
|
152
|
+
el.slot = slot;
|
|
153
|
+
el.textContent = String(text);
|
|
154
|
+
return el;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// SrcLoader lives in its own module; re-exported here for existing imports.
|
|
158
|
+
export { SrcLoader } from './src-loader.js';
|
|
@@ -1,83 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Bundle Registry — Vanilla Breeze
|
|
3
3
|
*
|
|
4
|
-
* Provides infrastructure for modular bundles to register
|
|
5
|
-
*
|
|
4
|
+
* Provides infrastructure for modular bundles to register components
|
|
5
|
+
* and activate full bundles at runtime.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
// ─── Effect registry ─────────────────────────────────────────────────────────
|
|
9
|
-
|
|
10
|
-
const effects = new Map()
|
|
11
|
-
let observer = null
|
|
12
|
-
const reducedMotionMq = window.matchMedia('(prefers-reduced-motion: reduce)')
|
|
13
|
-
|
|
14
|
-
function startObserver() {
|
|
15
|
-
if (observer) return
|
|
16
|
-
observer = new MutationObserver(mutations => {
|
|
17
|
-
for (const mutation of mutations) {
|
|
18
|
-
for (const node of mutation.addedNodes) {
|
|
19
|
-
if (node.nodeType !== Node.ELEMENT_NODE) continue
|
|
20
|
-
const el = /** @type {Element} */ (node)
|
|
21
|
-
matchEffects(el)
|
|
22
|
-
el.querySelectorAll('*').forEach(matchEffects)
|
|
23
|
-
}
|
|
24
|
-
for (const node of mutation.removedNodes) {
|
|
25
|
-
if (node.nodeType !== Node.ELEMENT_NODE) continue
|
|
26
|
-
const el = /** @type {Element} */ (node)
|
|
27
|
-
unmatchEffects(el)
|
|
28
|
-
el.querySelectorAll('*').forEach(unmatchEffects)
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
})
|
|
32
|
-
observer.observe(document.body, { childList: true, subtree: true })
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function matchEffects(el) {
|
|
36
|
-
for (const [, def] of effects) {
|
|
37
|
-
if (el.matches?.(def.selector)) {
|
|
38
|
-
if (reducedMotionMq.matches && def.reducedMotionFallback) {
|
|
39
|
-
def.reducedMotionFallback(el)
|
|
40
|
-
} else {
|
|
41
|
-
def.init(el)
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function unmatchEffects(el) {
|
|
48
|
-
for (const [, def] of effects) {
|
|
49
|
-
if (el.matches?.(def.selector) && def.destroy) {
|
|
50
|
-
def.destroy(el)
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Register a DOM effect that auto-initializes on matching elements.
|
|
57
|
-
*
|
|
58
|
-
* @param {string} name - Unique effect name
|
|
59
|
-
* @param {{ selector: string, init: (el: Element) => void, destroy?: (el: Element) => void, reducedMotionFallback?: (el: Element) => void }} def
|
|
60
|
-
*/
|
|
61
|
-
export function registerEffect(name, def) {
|
|
62
|
-
effects.set(name, def)
|
|
63
|
-
|
|
64
|
-
// Initialize on existing matching elements
|
|
65
|
-
document.querySelectorAll(def.selector).forEach(el => {
|
|
66
|
-
if (reducedMotionMq.matches && def.reducedMotionFallback) {
|
|
67
|
-
def.reducedMotionFallback(el)
|
|
68
|
-
} else {
|
|
69
|
-
def.init(el)
|
|
70
|
-
}
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
// Start shared observer lazily
|
|
74
|
-
if (document.body) {
|
|
75
|
-
startObserver()
|
|
76
|
-
} else {
|
|
77
|
-
document.addEventListener('DOMContentLoaded', startObserver, { once: true })
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
8
|
// ─── Component registry ──────────────────────────────────────────────────────
|
|
82
9
|
|
|
83
10
|
const components = new Map()
|
package/src/lib/dates.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calendar-date helpers shared by the date-driven components
|
|
3
|
+
* (gantt-chart, burndown-chart, product-roadmap, adr-wc).
|
|
4
|
+
*
|
|
5
|
+
* Planning dates are calendar days, not instants. Two rules keep them
|
|
6
|
+
* stable in every time zone:
|
|
7
|
+
*
|
|
8
|
+
* 1. `YYYY-MM-DD` is parsed as LOCAL midnight. `new Date('2026-04-01')`
|
|
9
|
+
* is UTC midnight, which renders as March 31 anywhere west of UTC.
|
|
10
|
+
* 2. Days are stepped with setDate(), never by adding 86 400 000 ms,
|
|
11
|
+
* which repeats or skips a day across a daylight-saving change.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})(?![\d:T])/;
|
|
15
|
+
const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse a calendar date. `YYYY-MM-DD` becomes local midnight; Date objects
|
|
19
|
+
* are copied and truncated to local midnight; ISO timestamps
|
|
20
|
+
* (`YYYY-MM-DDTHH:mm…`) and numbers are accepted and truncated too.
|
|
21
|
+
* Everything else returns null: the native Date parser is far too lenient
|
|
22
|
+
* (it turns "<img 1>" into 2001-01-01).
|
|
23
|
+
* @param {unknown} value
|
|
24
|
+
* @returns {Date | null}
|
|
25
|
+
*/
|
|
26
|
+
export function parseISODate(value) {
|
|
27
|
+
if (value == null || value === '') return null;
|
|
28
|
+
if (value instanceof Date) {
|
|
29
|
+
return Number.isNaN(value.getTime()) ? null : startOfDay(value);
|
|
30
|
+
}
|
|
31
|
+
if (typeof value === 'number') {
|
|
32
|
+
return Number.isFinite(value) ? startOfDay(new Date(value)) : null;
|
|
33
|
+
}
|
|
34
|
+
const s = String(value).trim();
|
|
35
|
+
const m = s.match(ISO_DATE);
|
|
36
|
+
if (m) {
|
|
37
|
+
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
|
38
|
+
// Reject roll-overs such as 2026-02-31.
|
|
39
|
+
if (d.getMonth() !== Number(m[2]) - 1 || d.getDate() !== Number(m[3])) return null;
|
|
40
|
+
return d;
|
|
41
|
+
}
|
|
42
|
+
if (!ISO_TIMESTAMP.test(s)) return null;
|
|
43
|
+
const d = new Date(s);
|
|
44
|
+
return Number.isNaN(d.getTime()) ? null : startOfDay(d);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** @param {Date} d @returns {Date} local midnight of the same calendar day */
|
|
48
|
+
export function startOfDay(d) {
|
|
49
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** @param {Date} d @returns {string} `YYYY-MM-DD` in local time */
|
|
53
|
+
export function toISODate(d) {
|
|
54
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
55
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
56
|
+
return `${d.getFullYear()}-${m}-${day}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Calendar-day addition (DST-safe). @param {Date} d @param {number} n @returns {Date} */
|
|
60
|
+
export function addDays(d, n) {
|
|
61
|
+
const out = startOfDay(d);
|
|
62
|
+
out.setDate(out.getDate() + n);
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Whole calendar days from `a` to `b` (b − a), ignoring DST hour shifts.
|
|
68
|
+
* @param {Date} a @param {Date} b @returns {number}
|
|
69
|
+
*/
|
|
70
|
+
export function daysBetween(a, b) {
|
|
71
|
+
const ua = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate());
|
|
72
|
+
const ub = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate());
|
|
73
|
+
return Math.round((ub - ua) / 86_400_000);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Every calendar day from `start` to `end` inclusive, as `YYYY-MM-DD`.
|
|
78
|
+
* @param {Date} start @param {Date} end @returns {string[]}
|
|
79
|
+
*/
|
|
80
|
+
export function enumerateDays(start, end) {
|
|
81
|
+
const out = [];
|
|
82
|
+
for (let d = startOfDay(start); d <= end; d = addDays(d, 1)) out.push(toISODate(d));
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** @param {Date} d @returns {Date} first day of the month */
|
|
87
|
+
export function startOfMonth(d) {
|
|
88
|
+
return new Date(d.getFullYear(), d.getMonth(), 1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** @param {Date} d @returns {Date} last day of the month */
|
|
92
|
+
export function endOfMonth(d) {
|
|
93
|
+
return new Date(d.getFullYear(), d.getMonth() + 1, 0);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** @param {Date} d @returns {Date} first day of the calendar quarter */
|
|
97
|
+
export function startOfQuarter(d) {
|
|
98
|
+
return new Date(d.getFullYear(), Math.floor(d.getMonth() / 3) * 3, 1);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** @param {Date} d @returns {Date} last day of the calendar quarter */
|
|
102
|
+
export function endOfQuarter(d) {
|
|
103
|
+
return new Date(d.getFullYear(), Math.floor(d.getMonth() / 3) * 3 + 3, 0);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Add whole months, clamping the day so Jan 31 + 1 month is Feb 28/29,
|
|
108
|
+
* not March 3.
|
|
109
|
+
* @param {Date} d @param {number} n @returns {Date}
|
|
110
|
+
*/
|
|
111
|
+
export function addMonths(d, n) {
|
|
112
|
+
const first = new Date(d.getFullYear(), d.getMonth() + n, 1);
|
|
113
|
+
const last = endOfMonth(first).getDate();
|
|
114
|
+
return new Date(first.getFullYear(), first.getMonth(), Math.min(d.getDate(), last));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Locale display of a calendar date. Defaults to e.g. "April 1, 2026".
|
|
119
|
+
* @param {Date} d
|
|
120
|
+
* @param {Intl.DateTimeFormatOptions} [opts]
|
|
121
|
+
* @param {string | string[]} [locale]
|
|
122
|
+
*/
|
|
123
|
+
export function formatDate(d, opts = { year: 'numeric', month: 'long', day: 'numeric' }, locale = undefined) {
|
|
124
|
+
return d.toLocaleDateString(locale, opts);
|
|
125
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pack's one HTML escaper. Use it for every author- or data-supplied
|
|
3
|
+
* string interpolated into an innerHTML template, in both text and
|
|
4
|
+
* attribute positions (single- or double-quoted).
|
|
5
|
+
*
|
|
6
|
+
* Do NOT use it for values passed to setAttribute() or textContent: those
|
|
7
|
+
* are already literal, and escaping them shows "&" to users and screen
|
|
8
|
+
* readers.
|
|
9
|
+
*
|
|
10
|
+
* @param {unknown} value
|
|
11
|
+
* @returns {string}
|
|
12
|
+
*/
|
|
13
|
+
export function esc(value) {
|
|
14
|
+
return String(value ?? '').replace(/[&<>"']/g, (c) => ESCAPES[c]);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** @type {Record<string, string>} */
|
|
18
|
+
const ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Escape a URL for use inside a CSS `url("…")` token in an inline style.
|
|
22
|
+
* Returns the full `url("…")` so callers can't forget the quotes. Only
|
|
23
|
+
* http(s), protocol-relative, relative, and data:image URLs are allowed;
|
|
24
|
+
* anything else yields `none`.
|
|
25
|
+
* @param {unknown} value
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
export function cssUrl(value) {
|
|
29
|
+
const s = String(value ?? '').trim();
|
|
30
|
+
if (!s) return 'none';
|
|
31
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(s) && !/^(https?:|data:image\/)/i.test(s)) return 'none';
|
|
32
|
+
const escaped = s.replace(/["\\\n\r\f]/g, (c) => `\\${c.charCodeAt(0).toString(16)} `);
|
|
33
|
+
return esc(`url("${escaped}")`);
|
|
34
|
+
}
|
package/src/lib/hash.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small deterministic string hash (Java-style `h * 31 + c`, written as
|
|
3
|
+
* `(h << 5) - h`). Used for stable avatar colours and portrait picks, so the
|
|
4
|
+
* exact output matters: changing it changes every generated avatar.
|
|
5
|
+
* @param {string} str
|
|
6
|
+
* @returns {number} integer (deliberately not truncated to 32 bits, to match
|
|
7
|
+
* the original copies byte for byte)
|
|
8
|
+
*/
|
|
9
|
+
export function stringHash(str) {
|
|
10
|
+
let h = 0;
|
|
11
|
+
const s = String(str ?? '');
|
|
12
|
+
for (let i = 0; i < s.length; i++) {
|
|
13
|
+
h = s.charCodeAt(i) + ((h << 5) - h);
|
|
14
|
+
}
|
|
15
|
+
return h;
|
|
16
|
+
}
|
package/src/lib/portrait-url.js
CHANGED
|
@@ -15,22 +15,11 @@
|
|
|
15
15
|
* // → 'https://cdn.jsdelivr.net/gh/faker-js/assets-person-portrait/male/256/82.jpg'
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import { stringHash } from './hash.js';
|
|
19
|
+
|
|
18
20
|
const CDN = 'https://cdn.jsdelivr.net/gh/faker-js/assets-person-portrait';
|
|
19
21
|
const SIZES = [32, 64, 128, 256, 512];
|
|
20
22
|
|
|
21
|
-
/**
|
|
22
|
-
* Simple string hash (same algorithm as _ux-base.js hashColor).
|
|
23
|
-
* @param {string} str
|
|
24
|
-
* @returns {number}
|
|
25
|
-
*/
|
|
26
|
-
function hash(str) {
|
|
27
|
-
let h = 0;
|
|
28
|
-
for (let i = 0; i < str.length; i++) {
|
|
29
|
-
h = str.charCodeAt(i) + ((h << 5) - h);
|
|
30
|
-
}
|
|
31
|
-
return h;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
23
|
/**
|
|
35
24
|
* Snap a pixel value to the nearest available portrait size.
|
|
36
25
|
* @param {number} px - Desired size in pixels
|
|
@@ -61,27 +50,12 @@ function snapSize(px) {
|
|
|
61
50
|
* @returns {string} CDN URL for the portrait JPEG
|
|
62
51
|
*/
|
|
63
52
|
export function portraitUrl(seed, size = 128) {
|
|
64
|
-
const h =
|
|
53
|
+
const h = stringHash(String(seed));
|
|
65
54
|
const index = ((h % 100) + 100) % 100;
|
|
66
55
|
const sex = ((h >>> 16) & 1) === 0 ? 'female' : 'male';
|
|
67
56
|
const snapped = snapSize(size);
|
|
68
57
|
return `${CDN}/${sex}/${snapped}/${index}.jpg`;
|
|
69
58
|
}
|
|
70
59
|
|
|
71
|
-
/**
|
|
72
|
-
* Get a portrait URL by explicit numeric index (0–199).
|
|
73
|
-
* 0–99 = female, 100–199 = male.
|
|
74
|
-
*
|
|
75
|
-
* @param {number} index - Portrait index (0–199)
|
|
76
|
-
* @param {number} [size=128] - Desired image size in px
|
|
77
|
-
* @returns {string} CDN URL
|
|
78
|
-
*/
|
|
79
|
-
export function portraitByIndex(index, size = 128) {
|
|
80
|
-
const i = ((index % 200) + 200) % 200;
|
|
81
|
-
const sex = i < 100 ? 'female' : 'male';
|
|
82
|
-
const fileIndex = i < 100 ? i : i - 100;
|
|
83
|
-
const snapped = snapSize(size);
|
|
84
|
-
return `${CDN}/${sex}/${snapped}/${fileIndex}.jpg`;
|
|
85
|
-
}
|
|
86
60
|
|
|
87
61
|
export { SIZES as PORTRAIT_SIZES, CDN as PORTRAIT_CDN };
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SrcLoader — one in-flight `src` fetch per component.
|
|
3
|
+
*
|
|
4
|
+
* Every component with a `src` attribute (the UXShadowElement cards, the
|
|
5
|
+
* boards, gantt-chart, review-surface) loads through this so the same
|
|
6
|
+
* rules hold everywhere:
|
|
7
|
+
*
|
|
8
|
+
* - A newer `start()` / `fetchJson()` aborts the previous request, and the
|
|
9
|
+
* superseded response (if it still arrives) is dropped.
|
|
10
|
+
* - `cancel()` aborts the in-flight request without starting another (the
|
|
11
|
+
* `src` attribute was removed, or `.tasks` / `.pins` took over).
|
|
12
|
+
* - `url` is the last URL requested, so connect-time code can skip a
|
|
13
|
+
* refetch of the same value; it is cleared on failure (allowing a retry)
|
|
14
|
+
* and by `cancel()`.
|
|
15
|
+
*/
|
|
16
|
+
export class SrcLoader {
|
|
17
|
+
/** @type {AbortController | null} */
|
|
18
|
+
#ctrl = null;
|
|
19
|
+
/** @type {string | null} */
|
|
20
|
+
url = null;
|
|
21
|
+
|
|
22
|
+
/** True while a fetchJson() request is in flight (not settled or cancelled). */
|
|
23
|
+
#busy = false;
|
|
24
|
+
get pending() { return this.#busy; }
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Abort any earlier request and begin tracking a new one.
|
|
28
|
+
* @param {string} url
|
|
29
|
+
* @returns {{ signal: AbortSignal, isCurrent: () => boolean }}
|
|
30
|
+
*/
|
|
31
|
+
start(url) {
|
|
32
|
+
this.#ctrl?.abort();
|
|
33
|
+
const ctrl = new AbortController();
|
|
34
|
+
this.#ctrl = ctrl;
|
|
35
|
+
this.#busy = false;
|
|
36
|
+
this.url = url;
|
|
37
|
+
return { signal: ctrl.signal, isCurrent: () => this.#ctrl === ctrl && !ctrl.signal.aborted };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Abort the in-flight request (if any) and forget the URL. Returns true
|
|
42
|
+
* when a request was actually in flight.
|
|
43
|
+
* @returns {boolean}
|
|
44
|
+
*/
|
|
45
|
+
cancel() {
|
|
46
|
+
const wasPending = this.pending;
|
|
47
|
+
this.#ctrl?.abort();
|
|
48
|
+
this.#ctrl = null;
|
|
49
|
+
this.#busy = false;
|
|
50
|
+
this.url = null;
|
|
51
|
+
return wasPending;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Fetch JSON for `url`, aborting any earlier request. Resolves to
|
|
56
|
+
* `undefined` when superseded, cancelled or aborted; rejects (only while
|
|
57
|
+
* still current) on a network error, a non-2xx status or invalid JSON.
|
|
58
|
+
* @param {string} url
|
|
59
|
+
* @returns {Promise<{ data: any, isCurrent: () => boolean } | undefined>}
|
|
60
|
+
*/
|
|
61
|
+
async fetchJson(url) {
|
|
62
|
+
const { signal, isCurrent } = this.start(url);
|
|
63
|
+
this.#busy = true;
|
|
64
|
+
try {
|
|
65
|
+
const res = await fetch(url, { signal });
|
|
66
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
67
|
+
const data = await res.json();
|
|
68
|
+
if (!isCurrent()) return undefined;
|
|
69
|
+
this.#busy = false;
|
|
70
|
+
return { data, isCurrent };
|
|
71
|
+
} catch (err) {
|
|
72
|
+
if (/** @type {any} */ (err)?.name === 'AbortError' || !isCurrent()) return undefined;
|
|
73
|
+
this.#busy = false;
|
|
74
|
+
this.url = null;
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -35,7 +35,13 @@ export function viewTransitionSwap(host, swap, prefix = 'vb-vt') {
|
|
|
35
35
|
const name = `${prefix}-${++counter}`;
|
|
36
36
|
host.style.viewTransitionName = name;
|
|
37
37
|
const tx = document.startViewTransition(swap);
|
|
38
|
-
|
|
38
|
+
// The browser aborts a transition when, e.g., the viewport resizes or
|
|
39
|
+
// another transition starts. That rejects `ready` (and can reject
|
|
40
|
+
// `finished`); unhandled, it surfaces as an uncaught exception even though
|
|
41
|
+
// the DOM swap itself already ran. The swap's own errors still reach
|
|
42
|
+
// callers through `updateCallbackDone`.
|
|
43
|
+
tx.ready.catch(() => {});
|
|
44
|
+
tx.finished.catch(() => {}).finally(() => {
|
|
39
45
|
if (host.style.viewTransitionName === name) {
|
|
40
46
|
host.style.viewTransitionName = '';
|
|
41
47
|
}
|