-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.jsx
More file actions
268 lines (257 loc) · 10.3 KB
/
Copy pathindex.jsx
File metadata and controls
268 lines (257 loc) · 10.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
// Reflection — thin app shell. The module tree is declared in mobius.json's
// source_files; the multi-file installer fetches each path and esbuild bundles
// from this entry, resolving the relative imports below at compile time.
//
// constants.js — shared scalar tables, report template blocks, and chat sizing constants
// theme.js — the single app stylesheet (CSS)
// domain.js — pure + DOM-level report, schedule, date, and split helpers
// providers.js — provider/model API loading helpers
// storage.js — storage layer, online signal, and chat split persistence keys
// ui/*.jsx — one React component per file
//
// Only App lives here: it owns top-level tab/detail state, persistence wiring,
// app-ready/dead-letter signals, and mounts the report/settings UI.
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { CSS } from './theme.js'
import { makeStorage, useOnline } from './storage.js'
import { LastNightStatus } from './ui/LastNightStatus.jsx'
import { ReportDetail } from './ui/ReportDetail.jsx'
import { ReportsList } from './ui/ReportsList.jsx'
import { SettingsTab } from './ui/SettingsTab.jsx'
export {
extractReportQuestions,
hardenReportHtml,
isDarkColor,
reportThemeStyle,
sanitizeQuestions,
} from './domain.js'
export { makeStorage } from './storage.js'
const SETUP_COMPLETIONS_KEY = 'mobius:setup-complete:v1'
function markSetupComplete(appId) {
if (appId == null || typeof window === 'undefined') return
try {
const parsed = JSON.parse(window.localStorage.getItem(SETUP_COMPLETIONS_KEY) || '{}')
const data = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
data[String(appId)] = { completedAt: new Date().toISOString() }
window.localStorage.setItem(SETUP_COMPLETIONS_KEY, JSON.stringify(data))
} catch {}
if (window.parent && window.parent !== window) {
window.parent.postMessage(
{ type: 'moebius:setup-complete', appId },
window.location.origin,
)
}
}
// ---------------------------------------------------------------------------
// App
// ---------------------------------------------------------------------------
export default function App({ appId, token }) {
const [tab, setTab] = useState('reports')
const [openDate, setOpenDate] = useState(null)
const detailNavRef = useRef(null)
const tabRefs = useRef([])
const online = useOnline()
const storage = useMemo(() => makeStorage(appId, token), [appId, token])
const selectTab = (next) => {
if (next === 'settings') closeDetail()
setTab(next)
}
const onTabKeyDown = (event, index) => {
const order = ['reports', 'settings']
let nextIndex = index
if (event.key === 'ArrowRight') nextIndex = (index + 1) % order.length
else if (event.key === 'ArrowLeft') nextIndex = (index - 1 + order.length) % order.length
else if (event.key === 'Home') nextIndex = 0
else if (event.key === 'End') nextIndex = order.length - 1
else return
event.preventDefault()
selectTab(order[nextIndex])
window.requestAnimationFrame(() => tabRefs.current[nextIndex]?.focus())
}
const appReadyFiredRef = useRef(false)
// A save can resolve 'queued' (durably outboxed offline) and then be FATALLY
// refused later, when the outbox drains — an async outcome the resolved
// promise at the call site can never carry. onDeadLetter is that out-of-band
// channel: it fires once per such write so a "Saved" the user already saw is
// honestly retracted here. Held at the app root because the originating
// component (a question card, the settings form) is likely unmounted by drain
// time. Replays unconsumed dead-letters on subscribe, so a refusal that
// landed while the app was closed still surfaces on next open.
const [deadLetter, setDeadLetter] = useState(null)
useEffect(() => {
if (!window.mobius || typeof window.mobius.onDeadLetter !== 'function') return undefined
return window.mobius.onDeadLetter((dl) => {
setDeadLetter(dl && dl.path === 'settings.json'
? 'Your schedule didn’t save — it was refused after going offline. Reopen Settings and save again.'
: 'A queued change couldn’t be saved after you reconnected. Please try again.')
})
}, [])
// Surface the streak in the header on the reports tab. The read below goes
// through the runtime read-through cache (offline-capable), so the badge
// fills from the last-known state.json even before the list finishes its own
// load — and offline too. The list keeps its own authoritative copy.
const [headerStreak, setHeaderStreak] = useState(0)
useEffect(() => {
let cancelled = false
;(async () => {
const res = await storage.getJSON('state.json')
if (cancelled) return
if (res.data && Number.isFinite(res.data.streak)) {
setHeaderStreak(res.data.streak)
}
// app_ready fires once after the initial state load (whether empty or not).
if (!appReadyFiredRef.current) {
appReadyFiredRef.current = true
window.mobius?.signal?.('app_ready')
}
})()
return () => { cancelled = true }
}, [storage, appId, token])
const closeDetail = useCallback(() => {
try { detailNavRef.current?.close?.() } catch {}
detailNavRef.current = null
setOpenDate(null)
}, [])
useEffect(() => {
function onMessage(e) {
if (e.origin !== window.location.origin) return
if (e.data?.type === 'moebius:app-intent' && e.data.intent === 'setup') {
closeDetail()
setTab('settings')
}
}
window.addEventListener('message', onMessage)
return () => window.removeEventListener('message', onMessage)
}, [closeDetail])
const openDetail = useCallback(async (dateStr) => {
try { detailNavRef.current?.close?.() } catch {}
detailNavRef.current = null
if (window.mobius?.nav?.open) {
const handle = window.mobius.nav.open('reflection-report', () => {
detailNavRef.current = null
setOpenDate(null)
})
detailNavRef.current = handle
await handle.ready?.catch(() => false)
if (detailNavRef.current !== handle) return
}
window.mobius?.signal?.('brief_opened', { date: dateStr })
setOpenDate(dateStr)
}, [appId, token])
useEffect(() => () => {
try { detailNavRef.current?.close?.() } catch {}
}, [])
return (
<div className="rf-root">
<style>{CSS}</style>
<h1 className="rf-sr-only">Reflection</h1>
<div className="rf-aurora" aria-hidden="true" />
<div className="rf-header">
<div className="rf-brand">
{/* Brand mark: the app's real glossy icon (downscaled + cached),
no name text. Falls back to an accent dot when this install
has no custom icon and the route 404s. */}
<img
src={`/api/apps/${appId}/icon?size=64`}
alt=""
width={26}
height={26}
className="rf-brand-icon"
onError={(e) => {
e.currentTarget.style.display = 'none'
const f = e.currentTarget.nextElementSibling
if (f) f.style.display = 'flex'
}}
/>
<span className="rf-brand-fallback" style={{ display: 'none' }} aria-hidden="true">·</span>
</div>
<div className="rf-header-right">
{headerStreak >= 1 && (
<span className="rf-streak-badge" title={`${headerStreak} mornings in a row`}>
<span aria-hidden="true">🔥</span>
{headerStreak}
</span>
)}
<div className="rf-seg" role="tablist" aria-label="View">
<button
id="rf-tab-reports"
ref={(node) => { tabRefs.current[0] = node }}
type="button"
role="tab"
aria-selected={tab === 'reports'}
aria-controls="rf-panel-reports"
tabIndex={tab === 'reports' ? 0 : -1}
className={`rf-seg-btn${tab === 'reports' ? ' is-active' : ''}`}
onClick={() => selectTab('reports')}
onKeyDown={(event) => onTabKeyDown(event, 0)}
>
Briefs
</button>
<button
id="rf-tab-settings"
ref={(node) => { tabRefs.current[1] = node }}
type="button"
role="tab"
aria-selected={tab === 'settings'}
aria-controls="rf-panel-settings"
tabIndex={tab === 'settings' ? 0 : -1}
className={`rf-seg-btn${tab === 'settings' ? ' is-active' : ''}`}
onClick={() => selectTab('settings')}
onKeyDown={(event) => onTabKeyDown(event, 1)}
>
Settings
</button>
</div>
</div>
</div>
<div className="rf-divider" />
<div className="rf-scroll">
{deadLetter && (
<div className="rf-deadletter" role="alert">
<span>{deadLetter}</span>
<button
type="button"
className="rf-deadletter__x rf-pressable"
aria-label="Dismiss"
onClick={() => setDeadLetter(null)}
>
×
</button>
</div>
)}
{tab === 'reports' ? (
<div id="rf-panel-reports" role="tabpanel" aria-labelledby="rf-tab-reports">
{/* Last-night status row — shows most recent cron_outcome for reflection */}
<LastNightStatus token={token} />
<ReportsList
appId={appId}
storage={storage}
online={online}
onOpen={openDetail}
onSetup={() => { closeDetail(); setTab('settings') }}
/>
{openDate && (
<ReportDetail
dateStr={openDate}
storage={storage}
online={online}
onBack={closeDetail}
appId={appId}
token={token}
/>
)}
</div>
) : (
<div id="rf-panel-settings" role="tabpanel" aria-labelledby="rf-tab-settings">
<SettingsTab
appId={appId}
storage={storage}
token={token}
onSetupComplete={() => markSetupComplete(appId)}
/>
</div>
)}
</div>
</div>
)
}