This commit is contained in:
ponzischeme89
2026-08-19 06:57:59 +12:00
parent 8c847c59b8
commit 2b43b9ef12
94 changed files with 6359 additions and 778 deletions
+19 -4
View File
@@ -40,6 +40,12 @@ interface PreferenceDefinition {
numbers?: number[];
unit?: string;
maxLength?: number;
/* Whether a text value is folded to capitals, and what an empty field means. Both come
from the catalogue rather than from this page: initials read as capitals and a person's
name does not, and a console that decided that for itself would drift from the server
the first time a text setting was added. */
uppercase?: boolean;
placeholder?: string;
adminOnly?: boolean;
}
@@ -101,6 +107,7 @@ interface AccountDetail {
id: string;
username: string;
initials: string;
shortName: string;
lastSeen: string;
devices: AccountDevice[] | null;
themes: string[] | null;
@@ -229,7 +236,9 @@ export function AccountPage() {
<>
<PageHead
title={account.username || 'Unnamed user'}
intro={`Memby user · ${num(devices.length)} device${devices.length === 1 ? '' : 's'} · last seen ${when(account.lastSeen)}`}
/* The short name is stated here as well as being editable below: it is what the
launcher calls this person, and the settings editor is a long way down the page. */
intro={`Memby user · ${account.shortName ? `greeted as ${account.shortName} · ` : ''}${num(devices.length)} device${devices.length === 1 ? '' : 's'} · last seen ${when(account.lastSeen)}`}
crumbs={<Link to="/admin/accounts"> All users</Link>}
actions={
<>
@@ -361,7 +370,7 @@ export function AccountPage() {
{account.watchTime?.matched ? (
<Card
title="Watch time"
intro="From Tracearr, for this person across every client — not only Memby. The week runs from Monday and the month from the first, both in the household's own time."
intro="From Tracearr, for this person across every client — not only Memby. The week runs from Monday and the month from the first, both in the server's own timezone."
icon="pulse"
tone="data"
actions={
@@ -845,8 +854,14 @@ function SettingControl({
type="text"
value={String(value ?? '')}
maxLength={definition.maxLength}
placeholder="Generated from their name"
onChange={(event) => onChange(event.target.value.toLocaleUpperCase('en-NZ'))}
placeholder={definition.placeholder}
onChange={(event) =>
onChange(
definition.uppercase
? event.target.value.toLocaleUpperCase('en-NZ')
: event.target.value,
)
}
/>
</Field>
);
+15 -3
View File
@@ -37,6 +37,7 @@ interface Account {
id: string;
username: string;
initials: string;
shortName: string;
lastSeen: string;
devices: KnownClient[] | null;
recommendations?: { prompted?: boolean; completed?: boolean };
@@ -100,7 +101,7 @@ export function AccountsPage() {
{ label: 'Memby users', value: num(accounts.length), icon: 'people', tone: 'note' },
{ label: 'signed-in devices', value: num(devices.length), icon: 'tv', tone: 'info' },
{
label: 'active in the last quarter hour',
label: 'active in the last 15 mins',
value: num(devices.filter((device) => recent(device.lastSeen)).length),
icon: 'pulse',
tone: 'ok',
@@ -110,7 +111,7 @@ export function AccountsPage() {
...(tracked.length
? [
{
label: 'watched by the household this week',
label: 'watch time this week',
value: watchTime(weekMs),
icon: 'pulse' as const,
tone: 'data' as const,
@@ -126,6 +127,7 @@ export function AccountsPage() {
<thead>
<tr>
<th>Person</th>
<th>Short name</th>
<th className="num">Devices</th>
<th className="num">This week</th>
<th className="num">This month</th>
@@ -135,7 +137,7 @@ export function AccountsPage() {
</thead>
<tbody>
{rows.length === 0 ? (
<EmptyRow columns={6}>
<EmptyRow columns={7}>
No one has signed in to Memby yet. Emby-only accounts are intentionally not listed here.
</EmptyRow>
) : (
@@ -163,6 +165,16 @@ export function AccountsPage() {
</Link>
</span>
</td>
{/* Blank is the ordinary state and not a gap: the launcher greets
somebody by their account name unless an operator has given
Memby a friendlier one, and saying so beats a bare dash. */}
<td>
{account.shortName || (
<span className="muted" title="Memby greets them by their account name">
account name
</span>
)}
</td>
<td className="num">
{num(list.length)}
{/* Only where there is something to say. A sub-line under every
+1 -1
View File
@@ -122,7 +122,7 @@ export function CreditsPage() {
<Card
title="Waiting candidates"
intro="The exact worker order after marker checks and retry cooldowns. A refresh may replace this list as household viewing changes."
intro="The exact worker order after marker checks and retry cooldowns. A refresh may replace this list as viewing changes."
icon="list"
tone="info"
>
+1 -1
View File
@@ -239,7 +239,7 @@ function LogView({
<Grid cols="wide">
<Card
title="Attempts per day"
intro="Grouped in the household's own timezone, so an evening sign-in stays on the day it happened."
intro="Grouped in the server's own timezone, so an evening sign-in stays on the day it happened."
icon="chart"
tone="info"
>
+29 -7
View File
@@ -47,6 +47,13 @@ import type { LogEvent, LogResponse } from '../api/types';
* because density is the reason this page is worth watching; a failure or a real
* application event earns a second. That means the virtual window is driven by a prefix
* sum of row heights rather than by multiplication, computed once per filter change.
* Which height a row gets and what it prints are one decision, made once, in
* `lib/logmodel`'s `secondary` — the table renders that field and nothing else under
* the summary. They were two, and they disagreed: every authenticated request line
* carries the viewer and the television, so a context line was drawn on rows the height
* rule had already ruled out, centred inside a box too short for it and sliced top and
* bottom. A request's identity now sits at the end of its own line instead, which keeps
* both the density and the fact.
* - **Pause holds the view, not the connection.** Draining continues while paused and the
* arrivals are held in a buffer, so the cursor keeps up with the server's ring buffer
* and resuming is a flush rather than a stampede — the previous behaviour let the ring
@@ -56,8 +63,20 @@ import type { LogEvent, LogResponse } from '../api/types';
const RETAIN = 20_000;
const POLL_MS = 5_000;
const ROW_COMPACT = 30;
const ROW_TALL = 48;
/* Row geometry, and it is arithmetic rather than a pair of round numbers: a row is
* absolutely positioned at a height this file decides, so anything the stylesheet draws
* that these figures do not account for is text clipped by a rule nobody can see from the
* CSS. Every first-line cell in `.logrow` is given exactly ROW_LINE, the secondary line
* exactly ROW_SECOND, and the padding and hairline below are the same on both heights —
* which is what makes the columns line up whether an event printed one line or two.
* Changing any figure here means changing its twin in `styles.css`. */
const ROW_PAD = 7;
const ROW_LINE = 16;
const ROW_SECOND = 15;
const ROW_SECOND_GAP = 2;
const ROW_RULE = 1;
const ROW_COMPACT = ROW_PAD + ROW_LINE + ROW_PAD + ROW_RULE;
const ROW_TALL = ROW_COMPACT + ROW_SECOND_GAP + ROW_SECOND;
const DAY_HEIGHT = 26;
const HEADER_HEIGHT = 31;
const OVERSCAN = 10;
@@ -175,7 +194,7 @@ const LogRow = memo(function LogRow({
<button
type="button"
className="logrow-summary"
title={view.detail || view.summary}
title={[view.summary, view.trail, view.secondary].filter(Boolean).join(' — ')}
onClick={() => onInspect(event.sequence)}
>
<span className="logrow-line">
@@ -185,11 +204,14 @@ const LogRow = memo(function LogRow({
</b>
) : null}
<span className="logrow-text">{view.summary}</span>
{view.trail ? <span className="logrow-trail">{view.trail}</span> : null}
</span>
{view.detail ? (
<span className="logrow-error"> {view.detail}</span>
) : view.context ? (
<span className="logrow-context">{view.context}</span>
{/* Printed if and only if the row was measured for it — see `secondary` in
lib/logmodel. */}
{view.secondary ? (
<span className="logrow-second" data-tone={view.secondaryTone}>
{view.secondaryTone === 'error' ? `${view.secondary}` : view.secondary}
</span>
) : null}
</button>
+2 -2
View File
@@ -107,7 +107,7 @@ export function MaintenancePage() {
{!loading ? (
<Card
title="Quiet time"
intro={`Pause new television requests and server background work every day in ${status?.quietTime?.timeZone ?? 'the household timezone'}. Work already under way finishes safely. The admin console and health checks stay available so the schedule can always be changed.`}
intro={`Pause new television requests and server background work every day in ${status?.quietTime?.timeZone ?? 'the server timezone'}. Work already under way finishes safely. The admin console and health checks stay available so the schedule can always be changed.`}
icon="clock"
tone={status?.quietTime?.active ? 'warn' : 'info'}
actions={status?.quietTime?.active ? <Tag tone="warn">active now</Tag> : quietEnabled ? <Tag tone="ok">scheduled</Tag> : <Tag>off</Tag>}
@@ -123,7 +123,7 @@ export function MaintenancePage() {
onChange={(next) => { setQuietEnabled(next); setQuietTouched(true); }}
/>
<div className="fields">
<Field label="Starts" hint="Uses the household's 24-hour clock.">
<Field label="Starts" hint="Uses the server's 24-hour clock.">
<input type="time" value={quietStart} onChange={(event) => { setQuietStart(event.target.value); setQuietTouched(true); }} />
</Field>
<Field label="Ends" hint="May be on the following day, for example 23:00 to 07:00.">
+532
View File
@@ -0,0 +1,532 @@
import { Fragment, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { query } from '../api/client';
import { useQuery } from '../lib/hooks';
import { num, when } from '../lib/format';
import type { Tone } from '../lib/format';
import {
Banner,
Bars,
Button,
Card,
EmptyRow,
Field,
Loading,
PageHead,
Segments,
TableWrap,
Tag,
Tiles,
} from '../components/ui';
import type {
NotificationFacet,
NotificationLogEntry,
NotificationLogResponse,
} from '../api/types';
/* Everything Memby sent.
*
* The page exists because that question used to be unanswerable without reading three
* subsystems' log lines: each feature both decided to notify somebody and performed the
* delivery itself, so there was no one place that knew a summary had gone out, or that a
* viewer's own preferences had quietly refused it. Every producer now goes through
* internal/notify, and this is the console's window on the trail that leaves behind.
*
* Its shape follows the sign-in history's, deliberately, because an operator arrives at
* both with a *question* rather than a browsing intention — "did the weekly summary go
* out", "why did nobody hear about that import". So the filters sit above the table and
* are always visible, each control maps to one server-side filter, and the table stays
* readable on its own: the drawer is for the full body and the delivery error, never for
* working out who a row was about. */
interface Filters {
user: string;
kind: string;
channel: string;
status: string;
source: string;
q: string;
from: string;
to: string;
}
const EMPTY: Filters = {
user: '',
kind: '',
channel: '',
status: '',
source: '',
q: '',
from: '',
to: '',
};
const WINDOWS = [
{ value: 1, label: 'Today' },
{ value: 7, label: '7 days' },
{ value: 30, label: '30 days' },
{ value: 90, label: '90 days' },
] as const;
const LIMIT = 100;
/* One tone per status, and they mean what they mean everywhere else in the console: green
is the verdict, amber is look at this, red is wrong. Skipped is deliberately *not* red —
a notification a viewer's own preferences declined is Memby working correctly, and
colouring it as a fault would send an operator to fix something nobody broke. */
const STATUS_TONE: Record<string, Tone> = {
sent: 'ok',
delivered: 'ok',
failed: 'bad',
pending: 'warn',
skipped: 'idle',
};
/* The channel is the one column that says what *kind* of thing happened, so it carries a
tone of its own from the console's neutral half: a person, the household, somebody
else's service. None of the three is a judgement. */
const CHANNEL_TONE: Record<string, Tone> = {
'in-app': 'note',
broadcast: 'info',
webhook: 'data',
};
const CHANNEL_LABEL: Record<string, string> = {
'in-app': 'In-app',
broadcast: 'Broadcast',
webhook: 'Webhook',
};
/** readable turns a stored slug into something an operator reads: "watch-time-week"
* becomes "Watch time week". The slug is still the filter value — this is display only,
* so a kind added tomorrow needs nothing here. */
function readable(slug: string): string {
if (!slug) return '—';
const spaced = slug.replace(/[-_.:]+/g, ' ').trim();
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
/** options builds a dropdown from a facet list, so it can never offer a value that matches
* nothing. The count rides in the label because "failed (0)" and a missing option are
* different answers to "has anything failed". */
function options(facets: NotificationFacet[] | undefined, label: (value: string) => string) {
return (facets ?? []).map((facet) => (
<option key={facet.value} value={facet.value}>
{label(facet.value)} ({facet.count})
</option>
));
}
export function NotificationsPage() {
const [days, setDays] = useState<number>(7);
const [filters, setFilters] = useState<Filters>(EMPTY);
const [page, setPage] = useState(0);
const [openId, setOpenId] = useState<number | null>(null);
// An explicit `from` wins over the window, the rule the sign-in history follows: an
// operator who typed a date meant it.
const params = useMemo(
() =>
query({
...filters,
days: filters.from ? undefined : days,
limit: LIMIT,
offset: page * LIMIT,
}),
[filters, days, page],
);
const log = useQuery<NotificationLogResponse>(`/admin/api/notification-log${params}`);
const data = log.data;
const update = (patch: Partial<Filters>) => {
setFilters((current) => ({ ...current, ...patch }));
setPage(0);
// The open row belongs to the page that was on screen. Leaving it open across a filter
// change would show a record the table underneath no longer contains.
setOpenId(null);
};
const active = Object.values(filters).some((value) => value !== '');
const totals = data?.totals;
const retention = data?.retentionDays ?? 90;
const shown = data?.entries.length ?? 0;
const from = !data || data.total === 0 ? 0 : page * LIMIT + 1;
return (
<>
<PageHead
title="Notifications"
intro="Everything Memby sent — a viewer's own news, the bar every television draws, and each outbound webhook — with what became of it. Every feature reports through one notification service, so this is the whole trail rather than whichever half a feature remembered to log."
/>
<Banner message={log.error} />
{totals ? (
<Tiles
tiles={[
{ label: 'Sent', value: num(totals.sent), icon: 'send', tone: 'ok' },
{
label: 'Confirmed',
value: num(totals.delivered),
icon: 'check',
tone: 'ok',
},
{
label: 'Failed',
value: num(totals.failed),
icon: 'alert',
tone: totals.failed > 0 ? 'bad' : undefined,
},
{
/* Skipped is on the tile row because it is the number that answers the
complaint this page is usually opened for: somebody was not told, and
Memby meant not to tell them. */
label: 'Skipped',
value: num(totals.skipped),
icon: 'filter',
tone: 'idle',
},
{ label: 'People reached', value: num(totals.users), icon: 'people', tone: 'note' },
{ label: 'History kept', value: `${retention} days`, small: true, icon: 'clock' },
]}
/>
) : null}
<div className="filters">
<Field label="Window">
<Segments
value={filters.from ? -1 : days}
options={WINDOWS.map((entry) => ({ value: entry.value as number, label: entry.label }))}
onChange={(next) => {
setDays(next);
update({ from: '', to: '' });
}}
/>
</Field>
<Field label="Person">
<select value={filters.user} onChange={(event) => update({ user: event.target.value })}>
<option value="">Anyone</option>
{(data?.users ?? []).map((user) => (
<option key={user.id} value={user.id}>
{user.username || user.id}
</option>
))}
</select>
</Field>
<Field label="Type">
<select value={filters.kind} onChange={(event) => update({ kind: event.target.value })}>
<option value="">Any type</option>
{options(data?.facets?.kinds, readable)}
</select>
</Field>
<Field label="Channel">
<select
value={filters.channel}
onChange={(event) => update({ channel: event.target.value })}
>
<option value="">Any channel</option>
{options(data?.facets?.channels, (value) => CHANNEL_LABEL[value] ?? readable(value))}
</select>
</Field>
<Field label="Status">
<select value={filters.status} onChange={(event) => update({ status: event.target.value })}>
<option value="">Any status</option>
{options(data?.facets?.statuses, readable)}
</select>
</Field>
<Field label="Source">
<select value={filters.source} onChange={(event) => update({ source: event.target.value })}>
<option value="">Any service</option>
{options(data?.facets?.sources, readable)}
</select>
</Field>
<Field label="From">
<input
type="date"
value={filters.from}
onChange={(event) => update({ from: event.target.value })}
/>
</Field>
<Field label="To">
<input
type="date"
value={filters.to}
onChange={(event) => update({ to: event.target.value })}
/>
</Field>
<Field label="Search" grow>
<input
type="search"
value={filters.q}
placeholder="Title, message, error or person"
onChange={(event) => update({ q: event.target.value })}
/>
</Field>
<div className="filter-actions">
{active ? (
<Button
variant="quiet"
size="sm"
onClick={() => {
setFilters(EMPTY);
setPage(0);
setOpenId(null);
}}
>
Clear
</Button>
) : null}
</div>
</div>
{log.loading && !data ? <Loading /> : null}
{data && data.days.length > 1 ? (
<Card
title="Notifications per day"
intro="Failures and deliberate skips are counted beside the deliveries, because a quiet week and a week nothing was allowed to send look identical otherwise."
icon="chart"
tone="info"
>
<Bars
data={data.days}
labelOf={(row: (typeof data.days)[number]) => row.day}
valueOf={(row: (typeof data.days)[number]) =>
row.sent + row.delivered + row.failed + row.skipped
}
toneOf={(row: (typeof data.days)[number]) => (row.failed > 0 ? 'bad' : undefined)}
title={(row: (typeof data.days)[number]) =>
`${row.day}: ${row.sent + row.delivered} sent, ${row.failed} failed, ${row.skipped} skipped`
}
/>
</Card>
) : null}
{data ? (
<Card
title="History"
intro="Newest first. A row says who, what and whether it worked on its own; open one for the whole message and the delivery response."
icon="send"
tone="ok"
actions={
<span className="filter-summary">
{data.total === 0
? 'nothing matches'
: `${num(from)}${num(from + shown - 1)} of ${num(data.total)}`}
</span>
}
footer={
data.total > LIMIT ? (
<>
<Button size="sm" disabled={page === 0} onClick={() => setPage(page - 1)}>
Newer
</Button>
<Button
size="sm"
disabled={(page + 1) * LIMIT >= data.total}
onClick={() => setPage(page + 1)}
>
Older
</Button>
</>
) : undefined
}
>
<TableWrap>
<table>
<thead>
<tr>
<th className="nowrap">When</th>
<th>Recipient</th>
<th>Type</th>
<th>Title</th>
<th>Channel</th>
<th>Source</th>
<th>Status</th>
<th aria-label="Details" />
</tr>
</thead>
<tbody>
{data.entries.length === 0 ? (
<EmptyRow columns={8}>No notifications match these filters.</EmptyRow>
) : (
data.entries.map((entry) => (
<Fragment key={entry.id}>
<Row
entry={entry}
open={openId === entry.id}
onToggle={() => setOpenId(openId === entry.id ? null : entry.id)}
/>
{openId === entry.id ? <DetailRow entry={entry} /> : null}
</Fragment>
))
)}
</tbody>
</table>
</TableWrap>
</Card>
) : null}
</>
);
}
function Row({
entry,
open,
onToggle,
}: {
entry: NotificationLogEntry;
open: boolean;
onToggle: () => void;
}) {
return (
<tr data-selected={open || undefined}>
<td className="nowrap muted">{when(entry.occurredAt)}</td>
<td>
{entry.userId ? (
<Link className="table-row-link" to={`/admin/accounts/${encodeURIComponent(entry.userId)}`}>
{entry.username || entry.userId}
</Link>
) : entry.target ? (
/* A webhook's row names the destination it went to. Its address is never sent to
the console — the URL is the credential — so this is the only thing that can
identify which channel a delivery landed in. */
<span className="muted">{entry.target}</span>
) : (
/* No recipient is a real answer rather than a missing one: a service alert is
the whole household being told something. */
<span className="quiet">Everyone</span>
)}
</td>
<td className="mono">{entry.kind || '—'}</td>
<td>{entry.title || <span className="quiet"></span>}</td>
<td className="nowrap">
<Tag tone={CHANNEL_TONE[entry.channel]}>
{CHANNEL_LABEL[entry.channel] ?? entry.channel}
</Tag>
</td>
<td className="muted">{readable(entry.source)}</td>
<td className="nowrap">
<Tag tone={STATUS_TONE[entry.status] ?? 'idle'}>{entry.status}</Tag>
</td>
<td className="nowrap">
<Button size="sm" variant="quiet" onClick={onToggle} icon={open ? 'close' : 'list'}>
{open ? 'Close' : 'Details'}
</Button>
</td>
</tr>
);
}
/* The drawer is the whole record: the message as it was written, the delivery response,
and the context the producer attached. It is a row inside the table rather than a panel
beside it, so the record stays under the row it belongs to while an operator reads down
a filtered list. */
function DetailRow({ entry }: { entry: NotificationLogEntry }) {
const metadata = Object.entries(entry.metadata ?? {}).filter(
([, value]) => value !== null && value !== undefined && String(value) !== '',
);
return (
<tr className="detail-row">
<td colSpan={8}>
<section className="logdrawer" aria-label={`Notification ${entry.id}`}>
<header className="logdrawer-head">
<div>
<p className="logdrawer-place">
<span>{CHANNEL_LABEL[entry.channel] ?? entry.channel}</span>
<span className="logrow-sep" aria-hidden="true">
</span>
{readable(entry.source)}
</p>
<b>{entry.title || readable(entry.kind)}</b>
{/* The failure and the reason share a line, because they are the same answer
to "why did this not arrive" — one from the provider, one from Memby. */}
{entry.detail ? <p className="logdrawer-error">{entry.detail}</p> : null}
</div>
<div className="logdrawer-actions">
<Tag tone={STATUS_TONE[entry.status] ?? 'idle'}>{entry.status}</Tag>
</div>
</header>
<div className="logdrawer-grid">
<div className="logdrawer-section">
<h4>Delivery</h4>
<dl>
<dt>Sent</dt>
<dd>{when(entry.occurredAt)}</dd>
{entry.eventAt ? (
<>
<dt>Event</dt>
<dd>{when(entry.eventAt)}</dd>
</>
) : null}
<dt>Channel</dt>
<dd>{CHANNEL_LABEL[entry.channel] ?? entry.channel}</dd>
<dt>Type</dt>
<dd>{entry.kind || '—'}</dd>
<dt>Service</dt>
<dd>{readable(entry.source)}</dd>
<dt>Took</dt>
<dd>{entry.durationMs}ms</dd>
</dl>
</div>
<div className="logdrawer-section">
<h4>Recipient</h4>
<dl>
<dt>Person</dt>
<dd>
{entry.userId ? entry.username || entry.userId : 'the whole household'}
</dd>
{entry.target ? (
<>
<dt>Destination</dt>
<dd>{entry.target}</dd>
</>
) : null}
{entry.itemId ? (
<>
<dt>Title id</dt>
<dd>{entry.itemId}</dd>
</>
) : null}
{entry.sourceKey ? (
<>
{/* The idempotency key is what explains a skip as a repeat rather than
as an unexplained gap, so it is printed rather than hidden. */}
<dt>Source key</dt>
<dd>{entry.sourceKey}</dd>
</>
) : null}
</dl>
</div>
{metadata.length ? (
<div className="logdrawer-section">
<h4>Context</h4>
<dl>
{metadata.map(([key, value]) => (
<Fragment key={key}>
<dt>{readable(key)}</dt>
<dd>{String(value)}</dd>
</Fragment>
))}
</dl>
</div>
) : null}
</div>
{entry.body ? (
<div className="logdrawer-raw">
<h4>Message</h4>
<p className="notification-body">{entry.body}</p>
</div>
) : null}
</section>
</td>
</tr>
);
}
+1 -1
View File
@@ -51,7 +51,7 @@ export function SearchesPage() {
<>
<PageHead
title="Searches"
intro="What the household has been looking for, and what it searched just now."
intro="What viewers have been looking for, and what was searched just now."
/>
<Banner message={error} />
+2 -2
View File
@@ -147,7 +147,7 @@ export function SettingsPage() {
>
<KeyValue
rows={[
{ label: 'Household timezone', value: effective.timezone || 'not set' },
{ label: 'Server timezone', value: effective.timezone || 'not set' },
{ label: 'Log level', value: effective.logLevel },
{ label: 'Sign-in expiry', value: describe(effective.sessionIdleDays, 'day') },
{ label: 'Emby health probe', value: describe(effective.embyHealthSeconds, 'second') },
@@ -176,7 +176,7 @@ export function SettingsPage() {
>
<div className="fields">
<Field
label="Household timezone"
label="Server timezone"
hint={`Deployed: ${deployed.timezone || 'not set'}. An IANA name, for example Pacific/Auckland. Decides what "today" means for the schedule rows, the home hero and the sign-in history.`}
>
<input
+2 -2
View File
@@ -25,12 +25,12 @@ export function ViewsPage() {
{ label: change(data?.today.viewers ?? 0, data?.lastWeek.viewers ?? 0), value: num(data?.today.viewers), icon: 'people', tone: 'note' },
{ label: 'busiest time today', value: data?.busiestHour || '—', small: true, icon: 'clock', tone: 'info' },
]} />
<Card title="Visits by day" intro="One visit is a signed-in home-screen opening. Viewers are distinct household profiles." icon="chart" tone="data">
<Card title="Visits by day" intro="One visit is a signed-in home-screen opening. Viewers are distinct signed-in profiles." icon="chart" tone="data">
<TableWrap><table><thead><tr><th>Day</th><th className="num">Visits</th><th className="num">Viewers</th></tr></thead><tbody>
{daily.length === 0 ? <EmptyRow columns={3}>No home-screen visits yet.</EmptyRow> : daily.map((row) => <tr key={row.label}><td>{row.label}</td><td className="num">{num(row.visits)}</td><td className="num">{num(row.viewers)}</td></tr>)}
</tbody></table></TableWrap>
</Card>
<Card title="Today by hour" intro="Local New Zealand time. Use this to see when the household is opening Memby." icon="clock" tone="info">
<Card title="Today by hour" intro="Local New Zealand time. Use this to see when viewers are opening Memby." icon="clock" tone="info">
<TableWrap><table><thead><tr><th>Hour</th><th className="num">Visits</th><th className="num">Viewers</th></tr></thead><tbody>
{hourly.length === 0 ? <EmptyRow columns={3}>No home-screen visits yet today.</EmptyRow> : hourly.map((row) => <tr key={row.label}><td>{row.label}</td><td className="num">{num(row.visits)}</td><td className="num">{num(row.viewers)}</td></tr>)}
</tbody></table></TableWrap>