create-pmos 0.124.0 → 0.126.0
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/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/scripts/nine-stages.js +81 -0
- package/payload/scripts/workflow-state.js +2 -0
- package/payload/state/discoveries.md +1 -1
- package/payload/state/log.md +1 -1
- package/workspace/c3-one-home-test.js +131 -0
- package/workspace/fixtures/initiatives-api/list.json +171 -3
- package/workspace/fixtures/initiatives-api/roadmap.json +228 -4
- package/workspace/fixtures/initiatives-api/sheet.json +57 -1
- package/workspace/fixtures/one-home-baseline.json +83 -0
- package/workspace/guide/briefing-cards.md +40 -0
- package/workspace/guide/index.md +1 -0
- package/workspace/guide/layout.md +2 -0
- package/workspace/lib/engine/nine-stages.js +81 -0
- package/workspace/lib/engine/workflow-state.js +2 -0
- package/workspace/lib/facts-async.js +51 -0
- package/workspace/lib/item-steps.js +52 -0
- package/workspace/lib/jira-status.js +8 -3
- package/workspace/lib/jobs-to-be-done.js +52 -0
- package/workspace/lib/needs-overdue.js +41 -0
- package/workspace/lib/outbox-store.js +87 -0
- package/workspace/lib/pm-items.js +115 -0
- package/workspace/lib/route-modules.js +21 -0
- package/workspace/lib/routes-briefing.js +109 -0
- package/workspace/lib/routes-c3-init.js +53 -0
- package/workspace/lib/routes-c3-life.js +119 -0
- package/workspace/lib/routes-c3-strategy.js +169 -0
- package/workspace/lib/routes-c3-timeline.js +191 -0
- package/workspace/lib/routes-calendar-import.js +60 -0
- package/workspace/lib/routes-calendar-views.js +61 -0
- package/workspace/lib/routes-doc-health.js +79 -0
- package/workspace/lib/routes-doc-sheet.js +169 -0
- package/workspace/lib/routes-forecast.js +85 -0
- package/workspace/lib/routes-hooks.js +121 -0
- package/workspace/lib/routes-initiatives.js +3 -0
- package/workspace/lib/routes-intake.js +24 -0
- package/workspace/lib/routes-outbox.js +97 -0
- package/workspace/lib/routes-pm-items.js +216 -0
- package/workspace/lib/routes-records.js +61 -0
- package/workspace/lib/routes-releases.js +129 -0
- package/workspace/lib/routes-report-facts.js +84 -0
- package/workspace/lib/routes-report-families.js +32 -0
- package/workspace/lib/routes-roadmap-ops.js +141 -0
- package/workspace/lib/routes-runs.js +119 -0
- package/workspace/lib/routes-signals.js +50 -0
- package/workspace/lib/run-answers.js +69 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/c3.html +26 -0
- package/workspace/public/pm/briefing-deck.js +413 -0
- package/workspace/public/pm/briefing-model.js +211 -0
- package/workspace/public/pm/c3-breakdown.js +240 -0
- package/workspace/public/pm/c3-brief.js +278 -0
- package/workspace/public/pm/c3-cal-strategy.js +528 -0
- package/workspace/public/pm/c3-case.js +185 -0
- package/workspace/public/pm/c3-daypart.js +176 -0
- package/workspace/public/pm/c3-demo-brief-ix.js +350 -0
- package/workspace/public/pm/c3-demo-cal-ix.js +74 -0
- package/workspace/public/pm/c3-demo-cal2.js +66 -0
- package/workspace/public/pm/c3-demo-cal3.js +173 -0
- package/workspace/public/pm/c3-demo-data.js +560 -0
- package/workspace/public/pm/c3-demo-init.js +373 -0
- package/workspace/public/pm/c3-demo-life.js +77 -0
- package/workspace/public/pm/c3-demo-misc2.js +46 -0
- package/workspace/public/pm/c3-demo-plan2.js +30 -0
- package/workspace/public/pm/c3-demo-rep-ix.js +126 -0
- package/workspace/public/pm/c3-demo-rep2.js +92 -0
- package/workspace/public/pm/c3-demo-term.js +153 -0
- package/workspace/public/pm/c3-demo-time.js +49 -0
- package/workspace/public/pm/c3-demo-work-ix.js +80 -0
- package/workspace/public/pm/c3-demo-work2.js +13 -0
- package/workspace/public/pm/c3-demo.js +113 -0
- package/workspace/public/pm/c3-doc-sheet.js +314 -0
- package/workspace/public/pm/c3-dock.js +180 -0
- package/workspace/public/pm/c3-docs.js +290 -0
- package/workspace/public/pm/c3-expand.js +96 -0
- package/workspace/public/pm/c3-init-extras.js +170 -0
- package/workspace/public/pm/c3-lifecycle-panels.js +476 -0
- package/workspace/public/pm/c3-misc-panels.js +89 -0
- package/workspace/public/pm/c3-plan-panels.js +163 -0
- package/workspace/public/pm/c3-plan.js +192 -0
- package/workspace/public/pm/c3-roadmap.js +249 -0
- package/workspace/public/pm/c3-sheet.js +107 -0
- package/workspace/public/pm/c3-shell.js +674 -0
- package/workspace/public/pm/c3-signals.js +196 -0
- package/workspace/public/pm/c3-strategy-panels.js +45 -0
- package/workspace/public/pm/c3-swipe.js +230 -0
- package/workspace/public/pm/c3-terminal-data.js +26 -0
- package/workspace/public/pm/c3-terminal-edge.js +91 -0
- package/workspace/public/pm/c3-terminal-icons.js +41 -0
- package/workspace/public/pm/c3-terminal-results.js +141 -0
- package/workspace/public/pm/c3-terminal.js +52 -0
- package/workspace/public/pm/c3-tickets.js +163 -0
- package/workspace/public/pm/c3-timeline.js +201 -0
- package/workspace/public/pm/c3-toast.js +16 -0
- package/workspace/public/pm/calendar-views.js +1014 -0
- package/workspace/public/pm/console3-lift.md +220 -0
- package/workspace/public/pm/console3.css +1547 -0
- package/workspace/public/pm/dest-initiatives.js +16 -3
- package/workspace/public/pm/dest-mywork.js +8 -1
- package/workspace/public/pm/dest-reports.js +47 -1
- package/workspace/public/pm/dest-today.js +55 -1
- package/workspace/public/pm/doc-health-panel.js +110 -0
- package/workspace/public/pm/doc-rail.js +9 -3
- package/workspace/public/pm/lifecycle-lens-model.js +228 -0
- package/workspace/public/pm/lifecycle-lens.js +261 -0
- package/workspace/public/pm/new-initiative-sheet.js +116 -21
- package/workspace/public/pm/outbox-panel.js +125 -0
- package/workspace/public/pm/pm-work-view.js +895 -0
- package/workspace/public/pm/releases-view.js +248 -0
- package/workspace/public/pm/report-families-view.js +503 -0
- package/workspace/public/pm/run-card.js +244 -0
- package/workspace/public/pm/sessions-panel.js +149 -0
- package/workspace/public/pm/shell-bell.js +162 -0
- package/workspace/public/pm/shell-model.js +83 -0
- package/workspace/public/pm/shell-phone.js +124 -0
- package/workspace/public/pm/shell-readaloud.js +53 -0
- package/workspace/public/pm/shell-recording.js +110 -0
- package/workspace/public/pm/shell-ticker.js +90 -0
- package/workspace/public/pm/slide-confirm.js +163 -0
- package/workspace/public/pm/timeline-forecast-view.js +164 -0
- package/workspace/public/pm/today-day.js +8 -2
- package/workspace/public/pm/tokens.css +1 -0
- package/workspace/server.js +5 -2
- package/workspace/test-files.json +21 -3
- package/workspace/test-lists/browser-suites-green.json +5 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/* pm/c3-case.js — the console3 case file, as a primitive (console3.html: caseHtml line 2275, the case builders dcase 2271 and
|
|
2
|
+
* CARDC/NEEDC 2213 on, confirmCase 2297, the claim labels C3.LAB at line 4524).
|
|
3
|
+
*
|
|
4
|
+
* normalizeCase(model) -> the canonical case (pure; accepts the mockup's field names and the descriptive ones)
|
|
5
|
+
* slideFor(caseModel, pickedId) -> { enabled, reason, label } for the slide under the case (pure)
|
|
6
|
+
* renderCase(model, opts) -> the `.case` element; opts: { picked, onPick(id, option), onConfirm({ option, hash, case }),
|
|
7
|
+
* outbox: boolean, onGo(go) }
|
|
8
|
+
*
|
|
9
|
+
* What the mockup's case file holds, top to bottom, and what this draws:
|
|
10
|
+
* quote `blockquote.qd` the exact text a draft would send (optional, `quote` / `pre`)
|
|
11
|
+
* situation `p.sit` one or two sentences: what is going on and why it needs a decision
|
|
12
|
+
* move `.c3ag` "Suggested move: ..." (optional, `move`)
|
|
13
|
+
* evidence `ul.chgl` one `li` per fact: icon, the claim, and a tag (the source, or a fact/estimate/assumption/reported
|
|
14
|
+
* label; tone ok / warn / bad colours the icon)
|
|
15
|
+
* options `.opts` buttons `.opt`, the recommended one first with `.rec`, its confidence ("Recommended · 82%") and a
|
|
16
|
+
* `.conf` bar; each carries its effects (`.fx`: icon + text, tone). One is pressed (aria-pressed,
|
|
17
|
+
* role=radio): the recommended one until the person picks another.
|
|
18
|
+
* confirm `.c3hold` R3: where the main pointer is fine, the slide below is replaced by a compact button: press and hold 600 ms
|
|
19
|
+
* (a ring fills) for an act that cannot be undone, one click for one that can, Enter then Confirm from
|
|
20
|
+
* the keyboard (c3-swipe.js mountHold, holdMode). `opts.fine` forces the choice; the default asks matchMedia.
|
|
21
|
+
* slide `.slt` the slide-to-confirm (slide-confirm.js mountSlide). "Slide to choose this instead" and the grey
|
|
22
|
+
* `.alt` look when the pick is not the recommended one. It is bound to the picked option: its
|
|
23
|
+
* confirm hands back `hash` = `<case id>|<option id>`, the content the person saw.
|
|
24
|
+
* footer `.cft` "Unsure: ...", "If you wait: ...", "Why shown: ...", and a link chip to the record
|
|
25
|
+
* Gate: an option or case marked `outward` (it sends something to a person) cannot be confirmed here unless `opts.outbox` is true
|
|
26
|
+
* (an outbox is connected). The slide is then drawn disabled (`.slt.off`) with the reason under it; nothing is sent.
|
|
27
|
+
* Built with el(); every string from data goes in as a text node.
|
|
28
|
+
*/
|
|
29
|
+
import { el } from './dom.js';
|
|
30
|
+
import { c3ic } from './pm-work-view.js';
|
|
31
|
+
import { mountSlide, disposeSlide } from './slide-confirm.js';
|
|
32
|
+
import { mountHold, holdMode, finePointer } from './c3-swipe.js';
|
|
33
|
+
|
|
34
|
+
/** The claim labels of the mockup (C3.LAB): [icon, tag, tone]. */
|
|
35
|
+
export const LABELS = {
|
|
36
|
+
fact: ['check', 'fact', 'ok'], est: ['clock', 'estimate', 'warn'], ass: ['alert', 'assumption', 'warn'],
|
|
37
|
+
rep: ['user', 'reported', ''], unl: ['alert', 'unlabelled', 'bad'],
|
|
38
|
+
};
|
|
39
|
+
const TONES = new Set(['', 'ok', 'warn', 'bad']);
|
|
40
|
+
const tone = (t) => (TONES.has(t) ? t : '');
|
|
41
|
+
const str = (v) => (v === null || v === undefined ? '' : String(v));
|
|
42
|
+
|
|
43
|
+
function pct(v) {
|
|
44
|
+
const n = Number(v);
|
|
45
|
+
if (!Number.isFinite(n) || n <= 0) return 0;
|
|
46
|
+
return Math.max(0, Math.min(100, Math.round(n > 0 && n <= 1 && !Number.isInteger(n) ? n * 100 : n)));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function evidenceOf(item) {
|
|
50
|
+
if (Array.isArray(item)) { // the mockup's [icon, text, tag, tone]
|
|
51
|
+
const [icon, text, tag, tn] = item;
|
|
52
|
+
return { icon: icon || 'info', text: str(text), tag: str(tag), tone: tone(tn), label: '' };
|
|
53
|
+
}
|
|
54
|
+
const o = item || {};
|
|
55
|
+
const L = o.label && LABELS[o.label];
|
|
56
|
+
const parts = [L ? L[1] : str(o.label), str(o.source || o.tag)].filter(Boolean);
|
|
57
|
+
return { icon: o.icon || (L ? L[0] : 'info'), text: str(o.text || o.claim), tag: parts.join(' · '), tone: tone(o.tone !== undefined ? o.tone : (L ? L[2] : '')), label: str(o.label) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function effectOf(fx) {
|
|
61
|
+
if (Array.isArray(fx)) return { icon: fx[0] || 'info', text: str(fx[1]), tone: tone(fx[2]) };
|
|
62
|
+
const o = fx || {};
|
|
63
|
+
return { icon: o.icon || 'info', text: str(o.text), tone: tone(o.tone) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function optionOf(o, i) {
|
|
67
|
+
const x = o || {};
|
|
68
|
+
return { id: str(x.id !== undefined ? x.id : 'o' + i), label: str(x.l !== undefined ? x.l : x.label), rec: !!(x.rec || x.recommended),
|
|
69
|
+
effects: (x.fx || x.effects || []).map(effectOf), outward: !!x.outward, irreversible: !!x.irreversible, confidence: x.conf !== undefined || x.confidence !== undefined ? pct(x.conf !== undefined ? x.conf : x.confidence) : null };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The canonical case. Exactly one option is the recommended one: the first marked, else the first (the mockup's recId). */
|
|
73
|
+
export function normalizeCase(model) {
|
|
74
|
+
const m = model || {};
|
|
75
|
+
const opts = (m.opts || m.options || []).map(optionOf);
|
|
76
|
+
const rec = opts.find((o) => o.rec) || opts[0] || null;
|
|
77
|
+
opts.forEach((o) => { o.rec = o === rec; });
|
|
78
|
+
const sorted = rec ? [rec, ...opts.filter((o) => o !== rec)] : []; // the recommended one leads, as caseHtml sorts
|
|
79
|
+
const go = m.go ? (Array.isArray(m.go) ? { action: m.go[0], key: m.go[1], label: str(m.go[2]) } : { action: m.go.action || '', key: m.go.key || '', label: str(m.go.label), href: m.go.href }) : null;
|
|
80
|
+
const mv = m.move || m.suggestedMove;
|
|
81
|
+
return {
|
|
82
|
+
id: str(m.id || m.key || ''),
|
|
83
|
+
tc: str(m.tc || m.tone || 'var(--acc)'),
|
|
84
|
+
quote: str(m.quote || m.pre),
|
|
85
|
+
situation: str(m.sit !== undefined ? m.sit : (m.situation !== undefined ? m.situation : '')),
|
|
86
|
+
move: mv ? str(typeof mv === 'object' ? mv.label : mv) : '',
|
|
87
|
+
evidence: (m.chg || m.evidence || []).map(evidenceOf),
|
|
88
|
+
options: sorted,
|
|
89
|
+
recId: rec ? rec.id : '',
|
|
90
|
+
confidence: pct(m.conf !== undefined ? m.conf : m.confidence !== undefined ? m.confidence : (rec && rec.confidence !== null ? rec.confidence : 0)),
|
|
91
|
+
unsure: str(m.unsure),
|
|
92
|
+
ifYouWait: str(m.ign !== undefined ? m.ign : m.ifYouWait),
|
|
93
|
+
why: str(m.why !== undefined ? m.why : m.whyShown),
|
|
94
|
+
cta: str(m.cta || 'Slide to confirm'),
|
|
95
|
+
go,
|
|
96
|
+
outward: !!m.outward,
|
|
97
|
+
outboxReason: str(m.outboxReason),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The slide under the case for the picked option. Disabled when the action goes outside and no outbox is connected. */
|
|
102
|
+
export function slideFor(c, pickedId, { outbox = false } = {}) {
|
|
103
|
+
const opt = c.options.find((o) => o.id === pickedId) || c.options.find((o) => o.id === c.recId) || null;
|
|
104
|
+
if (!opt) return { enabled: false, reason: '', label: '', option: null, none: true };
|
|
105
|
+
const isRec = opt.id === c.recId;
|
|
106
|
+
const out = opt.outward || c.outward;
|
|
107
|
+
const label = isRec ? c.cta : 'Slide to choose this instead';
|
|
108
|
+
if (out && !outbox) return { enabled: false, option: opt, label, reason: c.outboxReason || 'This sends something outside. No outbox is connected, so it stays a draft.' };
|
|
109
|
+
return { enabled: true, option: opt, label, reason: '' };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const fxList = (a) => el('div', { class: 'fx' }, ...a.map((f) => el('span', { class: f.tone }, c3ic(f.icon, 'sm'), f.text)));
|
|
113
|
+
|
|
114
|
+
export function renderCase(model, opts = {}) {
|
|
115
|
+
const c = normalizeCase(model);
|
|
116
|
+
const outbox = !!opts.outbox;
|
|
117
|
+
let picked = c.options.some((o) => o.id === opts.picked) ? opts.picked : c.recId;
|
|
118
|
+
const root = el('div', { class: 'case', 'data-case': c.id || null, style: '--tc:' + c.tc });
|
|
119
|
+
const optsHost = c.options.length ? el('div', { class: 'opts', role: 'radiogroup', 'aria-label': 'Options' }) : null;
|
|
120
|
+
const slideHost = c.options.length ? el('div', { class: 'c3slide' }) : null;
|
|
121
|
+
|
|
122
|
+
const drawOptions = () => {
|
|
123
|
+
optsHost.replaceChildren(...c.options.map((o) => {
|
|
124
|
+
const on = o.id === picked;
|
|
125
|
+
return el('button', { type: 'button', class: 'opt' + (o.rec ? ' rec' : ''), role: 'radio', 'aria-checked': String(on), 'aria-pressed': String(on), 'data-opt': o.id,
|
|
126
|
+
onclick: () => pick(o.id) },
|
|
127
|
+
el('span', { class: 'pk' }, c3ic('check', 'sm')),
|
|
128
|
+
el('span', { class: 'rk', title: 'Confidence from the sources listed above' }, o.rec ? [c3ic('spark', 'sm'), 'Recommended · ' + c.confidence + '%'] : null),
|
|
129
|
+
el('b', null, o.label),
|
|
130
|
+
fxList(o.effects),
|
|
131
|
+
o.rec ? el('div', { class: 'conf' }, el('i', { style: 'width:' + c.confidence + '%' })) : null);
|
|
132
|
+
}));
|
|
133
|
+
};
|
|
134
|
+
let disposeHold = null;
|
|
135
|
+
const drawSlide = () => {
|
|
136
|
+
disposeSlide(slideHost);
|
|
137
|
+
if (disposeHold) { disposeHold(); disposeHold = null; }
|
|
138
|
+
slideHost.replaceChildren();
|
|
139
|
+
const s = slideFor(c, picked, { outbox });
|
|
140
|
+
if (s.none) return;
|
|
141
|
+
const fine = opts.fine !== undefined ? !!opts.fine : finePointer();
|
|
142
|
+
if (s.enabled && fine) {
|
|
143
|
+
const trk = el('div', { class: 'c3confirm', 'data-case-slt': c.id || null });
|
|
144
|
+
slideHost.append(trk);
|
|
145
|
+
disposeHold = mountHold(trk, { label: s.option.label || s.label, mode: holdMode(s.option, c), hash: c.id + '|' + s.option.id, alt: s.option.id !== c.recId,
|
|
146
|
+
onConfirm: (hash) => { if (opts.onConfirm) opts.onConfirm({ option: s.option, hash, case: c }); } });
|
|
147
|
+
} else if (s.enabled) {
|
|
148
|
+
mountSlide(slideHost, { label: s.label, hash: c.id + '|' + s.option.id, onConfirm: (hash) => { if (opts.onConfirm) opts.onConfirm({ option: s.option, hash, case: c }); } });
|
|
149
|
+
const trk = slideHost.querySelector('.slt');
|
|
150
|
+
if (trk && s.option.id !== c.recId) trk.classList.add('alt');
|
|
151
|
+
if (trk && trk.dataset) trk.dataset.caseSlt = c.id;
|
|
152
|
+
} else {
|
|
153
|
+
slideHost.append(
|
|
154
|
+
el('div', { class: 'slt off', 'aria-disabled': 'true', 'data-case-slt': c.id || null, style: '--tc:var(--muted-foreground)' },
|
|
155
|
+
el('span', { class: 'lb' }, s.label + ' ›››'),
|
|
156
|
+
el('button', { type: 'button', class: 'th', disabled: true, 'aria-label': 'Confirm: ' + s.option.label + '. Not available: ' + s.reason }, c3ic('lock'))),
|
|
157
|
+
el('div', { class: 'slt-why', role: 'note' }, c3ic('lock', 'sm'), s.reason));
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
function pick(id) {
|
|
161
|
+
if (!c.options.some((o) => o.id === id)) return;
|
|
162
|
+
picked = id;
|
|
163
|
+
drawOptions(); drawSlide();
|
|
164
|
+
if (opts.onPick) opts.onPick(id, c.options.find((o) => o.id === id));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (c.quote) root.append(el('blockquote', { class: 'qd' }, c.quote));
|
|
168
|
+
root.append(el('p', { class: 'sit' }, c.situation));
|
|
169
|
+
if (c.move) root.append(el('div', { class: 'c3ag' }, el('div', { class: 'dim mono', style: 'font-size:var(--fs-2)' }, c3ic('spark', 'sm'), ' Suggested move: ' + c.move)));
|
|
170
|
+
if (c.evidence.length) {
|
|
171
|
+
root.append(el('ul', { class: 'chgl', role: 'list' }, ...c.evidence.map((r) => el('li', { class: r.tone }, c3ic(r.icon, 'sm'), el('span', null, r.text), r.tag ? el('span', { class: 'tag' }, r.tag) : null))));
|
|
172
|
+
}
|
|
173
|
+
if (optsHost) { drawOptions(); drawSlide(); root.append(optsHost, slideHost); }
|
|
174
|
+
const goBtn = c.go ? el('button', { type: 'button', class: 'chip2 lk', onclick: () => { if (opts.onGo) opts.onGo(c.go); } }, c3ic('next', 'sm'), c.go.label) : null;
|
|
175
|
+
if (c.unsure || c.ifYouWait || c.why || goBtn) {
|
|
176
|
+
root.append(el('div', { class: 'cft' },
|
|
177
|
+
c.unsure ? el('span', null, c3ic('info', 'sm'), 'Unsure: ' + c.unsure) : null,
|
|
178
|
+
c.ifYouWait ? el('span', null, c3ic('alert', 'sm'), 'If you wait: ' + c.ifYouWait) : null,
|
|
179
|
+
c.why ? el('span', null, c3ic('eye', 'sm'), 'Why shown: ' + c.why) : null,
|
|
180
|
+
goBtn));
|
|
181
|
+
}
|
|
182
|
+
/* a handle for the caller: read or set the pick, and tear the slide down when the case leaves the page */
|
|
183
|
+
root.c3case = { get picked() { return picked; }, pick, model: c, dispose() { if (slideHost) disposeSlide(slideHost); if (disposeHold) { disposeHold(); disposeHold = null; } } };
|
|
184
|
+
return root;
|
|
185
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/* pm/c3-daypart.js — the time of day for console3: the day part from the clock, the sun and moon on a daylight arc, and the gate that
|
|
2
|
+
* lets a new day part re-rank the Briefing only when the page is idle. No network and no geolocation prompt: the place is the browser's
|
|
3
|
+
* time zone (longitude, 15 degrees an hour) and one default latitude. The pure functions take the clock and the zone as arguments, so
|
|
4
|
+
* workspace/c3-daypart.test.js checks them without a browser. The DOM part (mountDaypart, applyDayPart) only runs in a page. */
|
|
5
|
+
|
|
6
|
+
export const DAYPARTS = ['morning', 'midday', 'afternoon', 'evening', 'night'];
|
|
7
|
+
/* Default latitude, degrees north, used when the page has no better idea. Mid-latitude, so day length is neither extreme. */
|
|
8
|
+
export const DEFAULT_LATITUDE = 45;
|
|
9
|
+
export const RECHECK_MS = 5 * 60 * 1000;
|
|
10
|
+
export const IDLE_MS = 60 * 1000;
|
|
11
|
+
|
|
12
|
+
/** The day part of a clock hour: morning 5 to 11, midday 11 to 14, afternoon 14 to 17, evening 17 to 21, night 21 to 5. */
|
|
13
|
+
export function dayPartOf(date) {
|
|
14
|
+
const d = date instanceof Date ? date : new Date(date);
|
|
15
|
+
const h = d.getHours();
|
|
16
|
+
return h >= 5 && h < 11 ? 'morning' : h >= 11 && h < 14 ? 'midday' : h >= 14 && h < 17 ? 'afternoon' : h >= 17 && h < 21 ? 'evening' : 'night';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** East longitude in degrees from a Date.getTimezoneOffset() value (minutes, positive west of UTC). Rough: a zone is 15 degrees wide. */
|
|
20
|
+
export function longitudeFromOffset(offsetMinutes) {
|
|
21
|
+
const o = Number(offsetMinutes);
|
|
22
|
+
return isFinite(o) ? Math.max(-180, Math.min(180, -o / 4)) : 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const RAD = Math.PI / 180;
|
|
26
|
+
const DAY_MS = 86400000;
|
|
27
|
+
|
|
28
|
+
/** Local calendar day [y, m, d] of an instant in a zone given as getTimezoneOffset minutes. */
|
|
29
|
+
function localYmd(ms, offsetMinutes) {
|
|
30
|
+
const d = new Date(ms - offsetMinutes * 60000);
|
|
31
|
+
return [d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Sunrise and sunset (ms since epoch) of the local day that holds `ms`. `polar` is 'day' or 'night' when the sun never sets or rises. */
|
|
35
|
+
export function sunTimes(ms, lat, lon, offsetMinutes) {
|
|
36
|
+
const [y, m, d] = localYmd(ms, offsetMinutes);
|
|
37
|
+
const jdNoon = Date.UTC(y, m, d, 12) / DAY_MS + 2440587.5; // the sunrise equation (NOAA): n counts days from J2000
|
|
38
|
+
const n = Math.round(jdNoon - 2451545.0 + 0.0008);
|
|
39
|
+
const jStar = n - lon / 360;
|
|
40
|
+
const M = (357.5291 + 0.98560028 * jStar) % 360;
|
|
41
|
+
const C = 1.9148 * Math.sin(M * RAD) + 0.02 * Math.sin(2 * M * RAD) + 0.0003 * Math.sin(3 * M * RAD);
|
|
42
|
+
const lam = (M + C + 180 + 102.9372) % 360;
|
|
43
|
+
const jTransit = 2451545.0 + jStar + 0.0053 * Math.sin(M * RAD) - 0.0069 * Math.sin(2 * lam * RAD);
|
|
44
|
+
const sinDec = Math.sin(lam * RAD) * Math.sin(23.4397 * RAD);
|
|
45
|
+
const cosDec = Math.cos(Math.asin(sinDec));
|
|
46
|
+
const cosW = (Math.sin(-0.833 * RAD) - Math.sin(lat * RAD) * sinDec) / (Math.cos(lat * RAD) * cosDec);
|
|
47
|
+
const toMs = (jd) => Math.round((jd - 2440587.5) * DAY_MS);
|
|
48
|
+
if (cosW > 1) return { sunrise: null, sunset: null, polar: 'night', transit: toMs(jTransit) };
|
|
49
|
+
if (cosW < -1) return { sunrise: null, sunset: null, polar: 'day', transit: toMs(jTransit) };
|
|
50
|
+
const w = Math.acos(cosW) / RAD;
|
|
51
|
+
return { sunrise: toMs(jTransit - w / 360), sunset: toMs(jTransit + w / 360), polar: null, transit: toMs(jTransit) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Where the sun or moon is now. body 'sun' while the sun is up, else 'moon'; t runs 0 to 1 along the arc of that body (sun: rise to set;
|
|
55
|
+
* moon: sunset to the next sunrise). In polar day or night t follows the clock around the transit. */
|
|
56
|
+
export function arcState(ms, lat, lon, offsetMinutes) {
|
|
57
|
+
const today = sunTimes(ms, lat, lon, offsetMinutes);
|
|
58
|
+
const out = { sunrise: today.sunrise, sunset: today.sunset, polar: today.polar, body: 'sun', t: 0.5, up: true };
|
|
59
|
+
if (today.polar) {
|
|
60
|
+
out.up = today.polar === 'day'; out.body = out.up ? 'sun' : 'moon';
|
|
61
|
+
out.t = Math.max(0, Math.min(1, ((ms - today.transit) / DAY_MS) + 0.5));
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
if (ms >= today.sunrise && ms <= today.sunset) {
|
|
65
|
+
out.t = (ms - today.sunrise) / (today.sunset - today.sunrise);
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
out.body = 'moon'; out.up = false;
|
|
69
|
+
if (ms < today.sunrise) {
|
|
70
|
+
const prev = sunTimes(ms - DAY_MS, lat, lon, offsetMinutes);
|
|
71
|
+
const from = prev.sunset == null ? today.sunrise - 12 * 3600000 : prev.sunset;
|
|
72
|
+
out.t = Math.max(0, Math.min(1, (ms - from) / (today.sunrise - from)));
|
|
73
|
+
} else {
|
|
74
|
+
const next = sunTimes(ms + DAY_MS, lat, lon, offsetMinutes);
|
|
75
|
+
const to = next.sunrise == null ? today.sunset + 12 * 3600000 : next.sunrise;
|
|
76
|
+
out.t = Math.max(0, Math.min(1, (ms - today.sunset) / (to - today.sunset)));
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Point on a half-ellipse arc from (pad, h - pad) over the top to (w - pad, h - pad). t 0 to 1. */
|
|
82
|
+
export function arcPoint(t, w, h, pad) {
|
|
83
|
+
const p = pad == null ? 4 : pad;
|
|
84
|
+
const a = Math.PI * (1 - Math.max(0, Math.min(1, t)));
|
|
85
|
+
const rx = (w - 2 * p) / 2;
|
|
86
|
+
return { x: +(w / 2 + rx * Math.cos(a)).toFixed(2), y: +(h - p - (h - 2 * p) * Math.sin(a)).toFixed(2) };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** "06:42" in the zone given as getTimezoneOffset minutes. */
|
|
90
|
+
export function hhmm(ms, offsetMinutes) {
|
|
91
|
+
if (ms == null) return '--:--';
|
|
92
|
+
const d = new Date(ms - offsetMinutes * 60000);
|
|
93
|
+
return String(d.getUTCHours()).padStart(2, '0') + ':' + String(d.getUTCMinutes()).padStart(2, '0');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The words for the strip, short sentences. */
|
|
97
|
+
export function stripLabel(st, offsetMinutes) {
|
|
98
|
+
if (st.polar === 'day') return 'The sun stays up all day.';
|
|
99
|
+
if (st.polar === 'night') return 'The sun stays down all day.';
|
|
100
|
+
return 'Sunrise ' + hhmm(st.sunrise, offsetMinutes) + '. Sunset ' + hhmm(st.sunset, offsetMinutes) + '.';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* ── the idle gate: a new day part re-ranks the Briefing only when nobody is reading ── */
|
|
104
|
+
|
|
105
|
+
/** Pure core. `part(ms)` gives the part for that time. `busy()` says a card is open. Call tick(ms) on a timer and touch(ms) on any
|
|
106
|
+
* pointer or key activity. `apply(part)` runs once per change, only when idle for idleMs and not busy. A part that arrives while
|
|
107
|
+
* busy waits (pending) and a later tick applies it. */
|
|
108
|
+
export function createIdleGate({ current, part, busy, apply, idleMs = IDLE_MS }) {
|
|
109
|
+
let shown = current; let pending = null; let last = -Infinity;
|
|
110
|
+
return {
|
|
111
|
+
touch(ms) { last = ms; },
|
|
112
|
+
tick(ms) {
|
|
113
|
+
const p = part(ms);
|
|
114
|
+
pending = p !== shown ? p : null;
|
|
115
|
+
if (pending && !(busy && busy()) && ms - last >= idleMs) { shown = pending; pending = null; apply(shown); return true; }
|
|
116
|
+
return false;
|
|
117
|
+
},
|
|
118
|
+
get shown() { return shown; },
|
|
119
|
+
get pending() { return pending; },
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Browser wiring of the gate: listens for activity on the document, checks every 5 minutes. Returns { dispose }. */
|
|
124
|
+
export function watchIdle(gate) {
|
|
125
|
+
const touch = () => gate.touch(Date.now());
|
|
126
|
+
const evs = ['pointerdown', 'pointermove', 'keydown', 'wheel', 'touchstart'];
|
|
127
|
+
evs.forEach((e) => document.addEventListener(e, touch, { passive: true, capture: true }));
|
|
128
|
+
gate.touch(Date.now());
|
|
129
|
+
const timer = setInterval(() => gate.tick(Date.now()), RECHECK_MS);
|
|
130
|
+
return { dispose() { clearInterval(timer); evs.forEach((e) => document.removeEventListener(e, touch, { capture: true })); } };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* ── DOM (browser only) ── */
|
|
134
|
+
|
|
135
|
+
/** Set data-daypart on an element. */
|
|
136
|
+
export function applyDayPart(elm, date) {
|
|
137
|
+
const p = dayPartOf(date || new Date());
|
|
138
|
+
if (elm && elm.getAttribute('data-daypart') !== p) elm.setAttribute('data-daypart', p);
|
|
139
|
+
return p;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const SVGNS = 'http://www.w3.org/2000/svg';
|
|
143
|
+
const sv = (tag, attrs) => { const e = document.createElementNS(SVGNS, tag); for (const k in attrs) e.setAttribute(k, attrs[k]); return e; };
|
|
144
|
+
|
|
145
|
+
/** The daylight strip: a small arc with the sun or moon and the sunrise and sunset times. About 22px tall. Appended to `host`.
|
|
146
|
+
* It sets data-daypart on `root` now and every 5 minutes. Returns { dispose }. */
|
|
147
|
+
export function mountDaypart(root, host, opts) {
|
|
148
|
+
const o = opts || {};
|
|
149
|
+
const lat = o.lat == null ? DEFAULT_LATITUDE : o.lat;
|
|
150
|
+
const W = 56; const H = 18;
|
|
151
|
+
const arc = sv('path', { class: 'dl-arc', d: 'M4 14 A24 12 0 0 1 52 14', fill: 'none' });
|
|
152
|
+
const base = sv('line', { class: 'dl-base', x1: 2, y1: 14.5, x2: 54, y2: 14.5 });
|
|
153
|
+
const body = sv('circle', { class: 'dl-body', r: 3, cx: 4, cy: 14 });
|
|
154
|
+
const svg = sv('svg', { class: 'dl-svg', viewBox: '0 0 ' + W + ' ' + H, width: W, height: H, 'aria-hidden': 'true', focusable: 'false' });
|
|
155
|
+
svg.append(base, arc, body);
|
|
156
|
+
const txt = document.createElement('span'); txt.className = 'dl-txt';
|
|
157
|
+
const strip = document.createElement('div');
|
|
158
|
+
strip.className = 'c3pg-daylight'; strip.setAttribute('data-c3pg', 'daylight'); strip.setAttribute('role', 'img');
|
|
159
|
+
strip.append(svg, txt);
|
|
160
|
+
host.append(strip);
|
|
161
|
+
const draw = () => {
|
|
162
|
+
const now = new Date(); const off = now.getTimezoneOffset();
|
|
163
|
+
applyDayPart(root, now);
|
|
164
|
+
const st = arcState(now.getTime(), lat, longitudeFromOffset(off), off);
|
|
165
|
+
const pt = arcPoint(st.t, W, H - 2, 4);
|
|
166
|
+
body.setAttribute('cx', pt.x); body.setAttribute('cy', pt.y);
|
|
167
|
+
strip.setAttribute('data-body', st.body);
|
|
168
|
+
const label = stripLabel(st, off);
|
|
169
|
+
txt.textContent = label;
|
|
170
|
+
strip.setAttribute('aria-label', (st.up ? 'Day. ' : 'Night. ') + label);
|
|
171
|
+
strip.title = (st.up ? 'The sun is up. ' : 'The moon is up. ') + label + ' Worked out on this device from the date and the time zone.';
|
|
172
|
+
};
|
|
173
|
+
draw();
|
|
174
|
+
const timer = setInterval(draw, RECHECK_MS);
|
|
175
|
+
return { dispose() { clearInterval(timer); strip.remove(); } };
|
|
176
|
+
}
|