// App root β sticky header (date + tab bar), data fetching, push registration.
const { api, buildSeries, TOKEN } = window.HC_DATA;
const SeriesContext = window.SeriesContext;
function PushBell({ theme }) {
const p = theme.palette;
const [state, setState] = React.useState('checking');
const [err, setErr] = React.useState(null);
React.useEffect(() => {
pushStatus().then(setState).catch(() => setState('off'));
}, []);
async function tap() {
if (state === 'on' || state === 'blocked' || state === 'unsupported' || state === 'enabling') return;
setErr(null); setState('enabling');
try {
await enablePush();
setState('on');
} catch (e) {
setErr(e.message || 'failed');
setState(Notification && Notification.permission === 'denied' ? 'blocked' : 'off');
}
}
// Don't render if push isn't supported on this device at all.
if (state === 'unsupported') return null;
const label = state === 'on' ? 'on'
: state === 'enabling' ? 'β¦'
: state === 'blocked' ? 'blocked'
: 'enable push';
const color = state === 'on' ? p.training
: state === 'blocked' ? p.warning
: p.accent;
return (
);
}
function TabBar({ active, onChange, theme }) {
const p = theme.palette;
const tabs = [['today', 'today'], ['log', 'log'], ['trends', 'trends'], ['ask', 'ask']];
return (
{tabs.map(([k, label]) => (
))}
);
}
function fmtHeaderDate(iso, dayName, dayType) {
if (!iso) return { left: 'β', right: '' };
const dt = new Date(iso + 'T00:00:00');
const months = ['January','February','March','April','May','June',
'July','August','September','October','November','December'];
const left = `${dayName || ''} ${months[dt.getMonth()]} ${dt.getDate()}`.trim();
const rightMap = {
wfh: 'wfh day', office: 'office day', rest: 'rest day',
saturday: 'saturday', sunday: 'sunday',
};
const right = rightMap[dayType] || '';
return { left, right };
}
// ββ Push registration ββββββββββββββββββββββββββββββββββββββββββββββ
// iOS Safari requires a user gesture to trigger Notification.requestPermission().
// Calling it on app load silently fails. We register the service worker on
// load so it's installed, but defer subscription until the user taps a button.
async function ensureServiceWorker() {
if (!('serviceWorker' in navigator)) return null;
try { return await navigator.serviceWorker.register('/sw.js'); }
catch (e) { console.warn('sw register failed:', e); return null; }
}
async function enablePush() {
const reg = await ensureServiceWorker();
if (!reg) throw new Error('service worker unsupported');
if (!('PushManager' in window)) throw new Error('push unsupported');
if (!('Notification' in window)) throw new Error('notifications unsupported');
if (Notification.permission === 'default') {
const perm = await Notification.requestPermission();
if (perm !== 'granted') throw new Error('permission denied');
} else if (Notification.permission === 'denied') {
throw new Error('permission denied');
}
const keyRes = await api('/api/vapid-public-key');
if (!keyRes || !keyRes.key) throw new Error('no vapid key');
let sub = await reg.pushManager.getSubscription();
if (!sub) {
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(keyRes.key),
});
}
await api('/api/push-subscribe', { method: 'POST', body: JSON.stringify(sub.toJSON()) });
return sub;
}
async function pushStatus() {
if (!('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) {
return 'unsupported';
}
if (Notification.permission === 'denied') return 'blocked';
if (Notification.permission !== 'granted') return 'off';
try {
const reg = await navigator.serviceWorker.getRegistration('/sw.js');
if (!reg) return 'off';
const sub = await reg.pushManager.getSubscription();
return sub ? 'on' : 'off';
} catch (e) { return 'off'; }
}
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(base64);
return Uint8Array.from([...raw].map((c) => c.charCodeAt(0)));
}
// ββ App ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function MissingToken({ theme }) {
const p = theme.palette;
const [input, setInput] = React.useState('');
function save() {
let t = input.trim();
if (!t) return;
// Accept full URL or just the token string.
try {
const u = new URL(t);
const tok = u.searchParams.get('token');
if (tok) t = tok;
} catch (e) { /* not a URL, use as-is */ }
window.HC_DATA.saveToken(t);
}
return (
Token needed
Paste your install URL or the token below. Saved to this device only.
);
}
function Loading({ theme }) {
const p = theme.palette;
return (
loadingβ¦
);
}
function ErrorView({ err, theme }) {
const p = theme.palette;
return (
Couldn't load
{err && err.detail ? JSON.stringify(err.detail) : (err && err.message) || 'unknown error'}
);
}
function App() {
const theme = window.HC_THEME.THEME;
const p = theme.palette;
const [tab, setTab] = React.useState('today');
const [todayData, setTodayData] = React.useState(null);
const [trendsData, setTrendsData] = React.useState(null);
const [err, setErr] = React.useState(null);
const refreshToday = React.useCallback(async () => {
try {
const d = await api('/api/today');
setTodayData(d);
return d;
} catch (e) { setErr(e); throw e; }
}, []);
React.useEffect(() => {
if (!TOKEN) return;
let alive = true;
(async () => {
try {
const [today, trends] = await Promise.all([
api('/api/today'),
api('/api/sleep-trends?months=5'),
]);
if (!alive) return;
setTodayData(today);
setTrendsData(trends);
} catch (e) {
if (alive) setErr(e);
}
})();
// Register SW eagerly (doesn't prompt), but defer push subscribe until
// user taps the bell β iOS Safari requires a user gesture.
ensureServiceWorker();
return () => { alive = false; };
}, []);
// Auto-refresh /api/today every 60s while on Today tab.
React.useEffect(() => {
if (!TOKEN || tab !== 'today') return;
const id = setInterval(() => { refreshToday().catch(() => {}); }, 60000);
return () => clearInterval(id);
}, [tab, refreshToday]);
// Refresh /api/today when the app regains focus (returning from background).
React.useEffect(() => {
if (!TOKEN) return;
function onVis() { if (!document.hidden) refreshToday().catch(() => {}); }
document.addEventListener('visibilitychange', onVis);
return () => document.removeEventListener('visibilitychange', onVis);
}, [refreshToday]);
if (!TOKEN) {
return (
);
}
const header = fmtHeaderDate(
todayData && todayData.day,
todayData && todayData.day_name,
todayData && todayData.day_type,
);
// Series for sparklines/charts: prefer the rich /api/sleep-trends data.
const series = React.useMemo(() => trendsData ? buildSeries(trendsData.days) : null, [trendsData]);
let body;
if (err && !todayData) {
body = ;
} else if (!todayData) {
body = ;
} else if (tab === 'today') {
body = ;
} else if (tab === 'log') {
body = ;
} else if (tab === 'trends') {
body = ;
} else {
body = ;
}
return (
);
}
ReactDOM.createRoot(document.getElementById('root')).render();