886 lines
22 KiB
TypeScript
886 lines
22 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
|
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
|
|
import { Activity, ChevronDown, ChevronUp } from 'lucide-react-native';
|
|
import {
|
|
calculateLiveMetrics,
|
|
calculateZones,
|
|
formatDuration,
|
|
} from '../training/trainingMetrics';
|
|
import {
|
|
type CompletedTrainingSession,
|
|
type HeartRateSample,
|
|
} from '../training/trainingTypes';
|
|
import {
|
|
buildContinuousUsageHistory,
|
|
filterSamplesByRange,
|
|
type ContinuousUsageBlock,
|
|
} from '../training/continuousUsage';
|
|
import { TrainingChart } from '../components/TrainingChart';
|
|
import { TrackRow } from '../components/TrackRow';
|
|
import { EmptyState } from '../components/EmptyState';
|
|
import { colors, spacing, typography } from '../theme';
|
|
|
|
type HistoryMode = 'windows' | 'continuous' | 'range';
|
|
|
|
type Props = {
|
|
readonly sessions: readonly CompletedTrainingSession[];
|
|
readonly getSessionSamples: (
|
|
sessionId: string,
|
|
) => Promise<readonly HeartRateSample[]>;
|
|
readonly getContinuousSamples: (
|
|
limit?: number,
|
|
) => Promise<readonly HeartRateSample[]>;
|
|
readonly liveSamples: readonly HeartRateSample[];
|
|
};
|
|
|
|
export function HistoryScreen({
|
|
sessions,
|
|
getSessionSamples,
|
|
getContinuousSamples,
|
|
liveSamples,
|
|
}: Props) {
|
|
const [mode, setMode] = useState<HistoryMode>('windows');
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
const [selectedSamples, setSelectedSamples] = useState<
|
|
readonly HeartRateSample[]
|
|
>([]);
|
|
const [selectedUsageId, setSelectedUsageId] = useState<string | null>(null);
|
|
const [loadingId, setLoadingId] = useState<string | null>(null);
|
|
const [loadingContinuous, setLoadingContinuous] = useState(true);
|
|
const [continuousSamples, setContinuousSamples] = useState<
|
|
readonly HeartRateSample[]
|
|
>([]);
|
|
const [rangeStartText, setRangeStartText] = useState(() =>
|
|
formatRangeInput(Date.now() - 60 * 60 * 1000),
|
|
);
|
|
const [rangeEndText, setRangeEndText] = useState(() =>
|
|
formatRangeInput(Date.now()),
|
|
);
|
|
|
|
useEffect(() => {
|
|
let mounted = true;
|
|
setLoadingContinuous(true);
|
|
|
|
getContinuousSamples()
|
|
.then(samples => {
|
|
if (mounted) {
|
|
setContinuousSamples(samples);
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (mounted) {
|
|
setLoadingContinuous(false);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
mounted = false;
|
|
};
|
|
}, [getContinuousSamples]);
|
|
|
|
const mergedContinuousSamples = useMemo(
|
|
() => mergeSamples(continuousSamples, liveSamples),
|
|
[continuousSamples, liveSamples],
|
|
);
|
|
|
|
const usageBlocks = useMemo(
|
|
() => buildContinuousUsageHistory(mergedContinuousSamples),
|
|
[mergedContinuousSamples],
|
|
);
|
|
const rangeBounds = useMemo(
|
|
() => parseHistoryRange(rangeStartText, rangeEndText),
|
|
[rangeStartText, rangeEndText],
|
|
);
|
|
const rangeSamples = useMemo(
|
|
() =>
|
|
rangeBounds
|
|
? filterSamplesByRange(mergedContinuousSamples, rangeBounds)
|
|
: [],
|
|
[mergedContinuousSamples, rangeBounds],
|
|
);
|
|
const selectedMetrics = useMemo(
|
|
() => calculateLiveMetrics(selectedSamples),
|
|
[selectedSamples],
|
|
);
|
|
const zones = useMemo(
|
|
() => calculateZones(selectedSamples),
|
|
[selectedSamples],
|
|
);
|
|
const selectedUsage =
|
|
usageBlocks.find(block => block.id === selectedUsageId) ?? null;
|
|
|
|
const openSession = async (session: CompletedTrainingSession) => {
|
|
if (selectedId === session.id) {
|
|
setSelectedId(null);
|
|
setSelectedSamples([]);
|
|
return;
|
|
}
|
|
|
|
setLoadingId(session.id);
|
|
setSelectedId(session.id);
|
|
try {
|
|
const samples = await getSessionSamples(session.id);
|
|
setSelectedSamples(samples);
|
|
} finally {
|
|
setLoadingId(null);
|
|
}
|
|
};
|
|
|
|
const openUsageBlock = (block: ContinuousUsageBlock) => {
|
|
if (selectedUsageId === block.id) {
|
|
setSelectedUsageId(null);
|
|
return;
|
|
}
|
|
|
|
setSelectedUsageId(block.id);
|
|
};
|
|
|
|
const totalWindowSamples = sessions.reduce(
|
|
(sum, session) => sum + session.sampleCount,
|
|
0,
|
|
);
|
|
const totalWindowDuration = sessions.reduce(
|
|
(sum, session) => sum + session.durationMs,
|
|
0,
|
|
);
|
|
const totalContinuousDuration = usageBlocks.reduce(
|
|
(sum, block) => sum + block.durationMs,
|
|
0,
|
|
);
|
|
|
|
return (
|
|
<View style={styles.screen}>
|
|
<View style={styles.modeSwitch}>
|
|
<ModeChip
|
|
active={mode === 'windows'}
|
|
label="Janelas"
|
|
onPress={() => setMode('windows')}
|
|
/>
|
|
<ModeChip
|
|
active={mode === 'continuous'}
|
|
label="Uso total"
|
|
onPress={() => setMode('continuous')}
|
|
/>
|
|
<ModeChip
|
|
active={mode === 'range'}
|
|
label="Faixa"
|
|
onPress={() => setMode('range')}
|
|
/>
|
|
</View>
|
|
|
|
{mode === 'windows' ? (
|
|
<WindowHistory
|
|
loadingId={loadingId}
|
|
onOpenSession={openSession}
|
|
selectedId={selectedId}
|
|
selectedMetrics={selectedMetrics}
|
|
selectedSamples={selectedSamples}
|
|
sessions={sessions}
|
|
totalDuration={totalWindowDuration}
|
|
totalSamples={totalWindowSamples}
|
|
zones={zones}
|
|
/>
|
|
) : null}
|
|
{mode === 'continuous' ? (
|
|
<ContinuousHistory
|
|
blocks={usageBlocks}
|
|
loading={loadingContinuous}
|
|
onOpenBlock={openUsageBlock}
|
|
selectedBlock={selectedUsage}
|
|
selectedUsageId={selectedUsageId}
|
|
totalDuration={totalContinuousDuration}
|
|
totalSamples={mergedContinuousSamples.length}
|
|
/>
|
|
) : null}
|
|
{mode === 'range' ? (
|
|
<RangeHistory
|
|
endText={rangeEndText}
|
|
rangeBounds={rangeBounds}
|
|
samples={rangeSamples}
|
|
setEndText={setRangeEndText}
|
|
setStartText={setRangeStartText}
|
|
startText={rangeStartText}
|
|
/>
|
|
) : null}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function ModeChip({
|
|
active,
|
|
label,
|
|
onPress,
|
|
}: {
|
|
readonly active: boolean;
|
|
readonly label: string;
|
|
readonly onPress: () => void;
|
|
}) {
|
|
return (
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityState={{ selected: active }}
|
|
onPress={onPress}
|
|
style={[styles.modeChip, active ? styles.modeChipActive : null]}
|
|
>
|
|
<Text
|
|
style={[styles.modeChipText, active ? styles.modeChipTextActive : null]}
|
|
>
|
|
{label}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
function RangeHistory({
|
|
endText,
|
|
rangeBounds,
|
|
samples,
|
|
setEndText,
|
|
setStartText,
|
|
startText,
|
|
}: {
|
|
readonly endText: string;
|
|
readonly rangeBounds: { readonly start: number; readonly end: number } | null;
|
|
readonly samples: readonly HeartRateSample[];
|
|
readonly setEndText: (value: string) => void;
|
|
readonly setStartText: (value: string) => void;
|
|
readonly startText: string;
|
|
}) {
|
|
const metrics = calculateLiveMetrics(samples);
|
|
|
|
return (
|
|
<View style={styles.rangePanel}>
|
|
<View style={styles.archiveHeader}>
|
|
<Text style={styles.eyebrow}>FAIXA CUSTOMIZADA</Text>
|
|
<Text style={styles.archiveLabel}>
|
|
Dia, hora e minuto. Use AAAA-MM-DD HH:mm para início e fim.
|
|
</Text>
|
|
</View>
|
|
|
|
<View style={styles.rangeGrid}>
|
|
<RangeInput
|
|
label="INÍCIO"
|
|
onChangeText={setStartText}
|
|
value={startText}
|
|
/>
|
|
<RangeInput label="FIM" onChangeText={setEndText} value={endText} />
|
|
</View>
|
|
|
|
{!rangeBounds ? (
|
|
<Text style={styles.rangeWarning}>
|
|
Informe as duas datas no formato AAAA-MM-DD HH:mm.
|
|
</Text>
|
|
) : null}
|
|
|
|
{rangeBounds && samples.length > 0 ? (
|
|
<View style={styles.rangeResult}>
|
|
<TrainingChart samples={samples} />
|
|
<View style={styles.detailRail}>
|
|
<DetailMetric label="média" value={metrics.averageBpm ?? '--'} />
|
|
<DetailMetric label="pico" value={metrics.peakBpm ?? '--'} />
|
|
<DetailMetric label="amostras" value={samples.length} />
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
|
|
{rangeBounds && samples.length === 0 ? (
|
|
<EmptyHistory
|
|
eyebrow="SEM AMOSTRAS"
|
|
title="Nada nesta faixa"
|
|
copy="A consulta usa apenas BPM real salvo no histórico contínuo. Ajuste o início ou fim para encontrar dados."
|
|
/>
|
|
) : null}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function RangeInput({
|
|
label,
|
|
onChangeText,
|
|
value,
|
|
}: {
|
|
readonly label: string;
|
|
readonly onChangeText: (value: string) => void;
|
|
readonly value: string;
|
|
}) {
|
|
return (
|
|
<View style={styles.rangeInputBlock}>
|
|
<Text style={styles.rangeInputLabel}>{label}</Text>
|
|
<TextInput
|
|
autoCapitalize="none"
|
|
keyboardType="numbers-and-punctuation"
|
|
onChangeText={onChangeText}
|
|
placeholder="2026-06-10 09:30"
|
|
placeholderTextColor={colors.textDim}
|
|
style={styles.rangeInput}
|
|
value={value}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function WindowHistory({
|
|
loadingId,
|
|
onOpenSession,
|
|
selectedId,
|
|
selectedMetrics,
|
|
selectedSamples,
|
|
sessions,
|
|
totalDuration,
|
|
totalSamples,
|
|
zones,
|
|
}: {
|
|
readonly loadingId: string | null;
|
|
readonly onOpenSession: (session: CompletedTrainingSession) => void;
|
|
readonly selectedId: string | null;
|
|
readonly selectedMetrics: ReturnType<typeof calculateLiveMetrics>;
|
|
readonly selectedSamples: readonly HeartRateSample[];
|
|
readonly sessions: readonly CompletedTrainingSession[];
|
|
readonly totalDuration: number;
|
|
readonly totalSamples: number;
|
|
readonly zones: ReturnType<typeof calculateZones>;
|
|
}) {
|
|
if (sessions.length === 0) {
|
|
return (
|
|
<EmptyHistory
|
|
eyebrow="JANELAS SALVAS"
|
|
title="Nenhuma janela salva"
|
|
copy="O monitor grava continuamente; a janela só destaca um intervalo que você abriu e fechou para análise."
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<View style={styles.archiveHeader}>
|
|
<Text style={styles.eyebrow}>JANELAS SALVAS</Text>
|
|
<Text style={styles.archiveNumber}>{sessions.length}</Text>
|
|
<Text style={styles.archiveLabel}>
|
|
{formatDuration(totalDuration)} · {totalSamples} amostras reais
|
|
destacadas
|
|
</Text>
|
|
</View>
|
|
|
|
<View style={styles.timeline}>
|
|
{sessions.map((session, index) => {
|
|
const isOpen = selectedId === session.id;
|
|
return (
|
|
<View key={session.id} style={styles.timelineNode}>
|
|
<TrackRow
|
|
accessibilityLabel={`Janela ${formatSessionDay(session.startedAt)} ${formatSessionHour(session.startedAt)}`}
|
|
day={formatSessionDay(session.startedAt)}
|
|
hour={formatSessionHour(session.startedAt)}
|
|
isOpen={isOpen}
|
|
meta={`${formatDuration(session.durationMs)} · ${session.sampleCount} amostras`}
|
|
metrics={[
|
|
{ label: `MÉD ${session.averageBpm}` },
|
|
{ label: `PICO ${session.peakBpm}`, tone: 'hot' as const },
|
|
{ label: `MIN ${session.minBpm}` },
|
|
]}
|
|
onPress={() => onOpenSession(session)}
|
|
title={session.deviceName || 'Cinta cardíaca BLE'}
|
|
trackNumber={index + 1}
|
|
>
|
|
|
|
{isOpen ? (
|
|
<WindowDetail
|
|
loading={loadingId === session.id}
|
|
metrics={selectedMetrics}
|
|
samples={selectedSamples}
|
|
zones={zones}
|
|
/>
|
|
) : null}
|
|
</TrackRow>
|
|
</View>
|
|
);
|
|
})}
|
|
</View>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ContinuousHistory({
|
|
blocks,
|
|
loading,
|
|
onOpenBlock,
|
|
selectedBlock,
|
|
selectedUsageId,
|
|
totalDuration,
|
|
totalSamples,
|
|
}: {
|
|
readonly blocks: readonly ContinuousUsageBlock[];
|
|
readonly loading: boolean;
|
|
readonly onOpenBlock: (block: ContinuousUsageBlock) => void;
|
|
readonly selectedBlock: ContinuousUsageBlock | null;
|
|
readonly selectedUsageId: string | null;
|
|
readonly totalDuration: number;
|
|
readonly totalSamples: number;
|
|
}) {
|
|
if (loading) {
|
|
return (
|
|
<View style={styles.loadingLine}>
|
|
<Activity color={colors.warning} size={16} strokeWidth={2.4} />
|
|
<Text style={styles.copy}>Carregando histórico contínuo real...</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (blocks.length === 0) {
|
|
return (
|
|
<EmptyHistory
|
|
eyebrow="USO TOTAL"
|
|
title="Sem uso contínuo ainda"
|
|
copy="Conecte a cinta. Todo BPM real recebido fora ou dentro de uma janela aparecerá aqui."
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<View style={styles.archiveHeader}>
|
|
<Text style={styles.eyebrow}>USO TOTAL</Text>
|
|
<Text style={styles.archiveNumber}>{blocks.length}</Text>
|
|
<Text style={styles.archiveLabel}>
|
|
{formatDuration(totalDuration)} · {totalSamples} amostras contínuas
|
|
salvas
|
|
</Text>
|
|
</View>
|
|
|
|
<View style={styles.timeline}>
|
|
{blocks.map((block, index) => {
|
|
const isOpen = selectedUsageId === block.id;
|
|
return (
|
|
<View key={block.id} style={styles.timelineNode}>
|
|
<TrackRow
|
|
accessibilityLabel={`Uso contínuo ${formatSessionDay(block.startedAt)}`}
|
|
day={formatSessionDay(block.startedAt)}
|
|
hour={formatSessionHour(block.startedAt)}
|
|
isOpen={isOpen}
|
|
meta={`${formatUsageRange(block)} · ${block.sampleCount} amostras`}
|
|
metrics={[
|
|
{ label: `MÉD ${block.averageBpm}` },
|
|
{ label: `PICO ${block.peakBpm}`, tone: 'hot' as const },
|
|
{ label: `MIN ${block.minBpm}` },
|
|
]}
|
|
onPress={() => onOpenBlock(block)}
|
|
title={block.deviceName || 'Uso contínuo'}
|
|
trackNumber={index + 1}
|
|
>
|
|
|
|
{isOpen && selectedBlock ? (
|
|
<UsageDetail block={selectedBlock} />
|
|
) : null}
|
|
</TrackRow>
|
|
</View>
|
|
);
|
|
})}
|
|
</View>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function WindowDetail({
|
|
loading,
|
|
metrics,
|
|
samples,
|
|
zones,
|
|
}: {
|
|
readonly loading: boolean;
|
|
readonly metrics: ReturnType<typeof calculateLiveMetrics>;
|
|
readonly samples: readonly HeartRateSample[];
|
|
readonly zones: ReturnType<typeof calculateZones>;
|
|
}) {
|
|
return (
|
|
<View style={styles.detailPanel}>
|
|
{loading ? (
|
|
<View style={styles.loadingLine}>
|
|
<Activity color={colors.warning} size={16} strokeWidth={2.4} />
|
|
<Text style={styles.copy}>Carregando amostras salvas...</Text>
|
|
</View>
|
|
) : null}
|
|
|
|
<TrainingChart samples={samples} />
|
|
<View style={styles.detailRail}>
|
|
<DetailMetric label="carga" value={metrics.loadScore || '--'} />
|
|
<DetailMetric label="alta" value={`${metrics.highIntensityPercent}%`} />
|
|
<DetailMetric
|
|
label="taxa"
|
|
value={`${metrics.sampleRatePerMinute ?? '--'}/min`}
|
|
/>
|
|
</View>
|
|
<View style={styles.zoneStack}>
|
|
{zones.map(zone => (
|
|
<View key={zone.key} style={styles.zoneRow}>
|
|
<Text style={styles.zoneLabel}>{zone.label}</Text>
|
|
<Text style={styles.zoneValue}>
|
|
{zone.percent}% · {formatDuration(zone.seconds * 1000)}
|
|
</Text>
|
|
</View>
|
|
))}
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function UsageDetail({ block }: { readonly block: ContinuousUsageBlock }) {
|
|
const metrics = calculateLiveMetrics(block.samples);
|
|
|
|
return (
|
|
<View style={styles.detailPanel}>
|
|
<TrainingChart samples={block.samples} />
|
|
<View style={styles.detailRail}>
|
|
<DetailMetric
|
|
label="duração"
|
|
value={formatDuration(block.durationMs)}
|
|
/>
|
|
<DetailMetric label="amostras" value={block.sampleCount} />
|
|
<DetailMetric
|
|
label="taxa"
|
|
value={`${metrics.sampleRatePerMinute ?? '--'}/min`}
|
|
/>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function EmptyHistory({
|
|
copy,
|
|
eyebrow,
|
|
title,
|
|
}: {
|
|
readonly copy: string;
|
|
readonly eyebrow: string;
|
|
readonly title: string;
|
|
}) {
|
|
return (
|
|
<EmptyState
|
|
copy={copy}
|
|
eyebrow={eyebrow}
|
|
title={title}
|
|
tone={eyebrow === 'ANÁLISE BLOQUEADA' ? 'red' : 'amber'}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function DetailMetric({
|
|
label,
|
|
value,
|
|
}: {
|
|
readonly label: string;
|
|
readonly value: string | number;
|
|
}) {
|
|
return (
|
|
<View style={styles.detailMetric}>
|
|
<Text style={styles.detailValue}>{value}</Text>
|
|
<Text style={styles.detailLabel}>{label}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function OpenIcon() {
|
|
return <ChevronUp color={colors.warning} size={18} strokeWidth={2.6} />;
|
|
}
|
|
|
|
function ClosedIcon() {
|
|
return <ChevronDown color={colors.textMuted} size={18} strokeWidth={2.6} />;
|
|
}
|
|
|
|
function formatSessionDay(timestamp: number) {
|
|
return new Date(timestamp).toLocaleDateString('pt-BR', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
});
|
|
}
|
|
|
|
function formatSessionHour(timestamp: number) {
|
|
return new Date(timestamp).toLocaleTimeString('pt-BR', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
}
|
|
|
|
function formatUsageRange(block: ContinuousUsageBlock) {
|
|
return `${formatDuration(block.durationMs)} · ${formatSessionHour(
|
|
block.startedAt,
|
|
)}-${formatSessionHour(block.endedAt)}`;
|
|
}
|
|
|
|
function mergeSamples(
|
|
persistedSamples: readonly HeartRateSample[],
|
|
liveSamples: readonly HeartRateSample[],
|
|
) {
|
|
const map = new Map<string, HeartRateSample>();
|
|
|
|
[...persistedSamples, ...liveSamples].forEach(sample => {
|
|
const key = sample.id
|
|
? `id:${sample.id}`
|
|
: `ts:${sample.timestamp}:${sample.bpm}`;
|
|
map.set(key, sample);
|
|
});
|
|
|
|
return [...map.values()].sort(
|
|
(left, right) => left.timestamp - right.timestamp,
|
|
);
|
|
}
|
|
|
|
function parseHistoryRange(startText: string, endText: string) {
|
|
const start = parseRangeInput(startText);
|
|
const end = parseRangeInput(endText);
|
|
|
|
if (start === null || end === null) {
|
|
return null;
|
|
}
|
|
|
|
return { start, end };
|
|
}
|
|
|
|
function parseRangeInput(value: string) {
|
|
const match = /^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})$/.exec(
|
|
value.trim(),
|
|
);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
|
|
const [, year, month, day, hour, minute] = match.map(Number);
|
|
const timestamp = new Date(
|
|
year,
|
|
month - 1,
|
|
day,
|
|
hour,
|
|
minute,
|
|
0,
|
|
0,
|
|
).getTime();
|
|
return Number.isNaN(timestamp) ? null : timestamp;
|
|
}
|
|
|
|
function formatRangeInput(timestamp: number) {
|
|
const date = new Date(timestamp);
|
|
const year = date.getFullYear();
|
|
const month = `${date.getMonth() + 1}`.padStart(2, '0');
|
|
const day = `${date.getDate()}`.padStart(2, '0');
|
|
const hour = `${date.getHours()}`.padStart(2, '0');
|
|
const minute = `${date.getMinutes()}`.padStart(2, '0');
|
|
return `${year}-${month}-${day} ${hour}:${minute}`;
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
screen: {
|
|
gap: spacing.md,
|
|
},
|
|
modeSwitch: {
|
|
borderTopWidth: 0,
|
|
borderBottomWidth: 1,
|
|
borderColor: colors.line,
|
|
flexDirection: 'row',
|
|
paddingVertical: spacing.xs,
|
|
gap: spacing.xs,
|
|
},
|
|
modeChip: {
|
|
flex: 1,
|
|
minHeight: 40,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
borderWidth: 1,
|
|
borderColor: 'transparent',
|
|
},
|
|
modeChipActive: {
|
|
borderColor: colors.line,
|
|
backgroundColor: colors.surfaceHot,
|
|
},
|
|
modeChipText: {
|
|
...typography.label,
|
|
color: colors.textMuted,
|
|
},
|
|
modeChipTextActive: {
|
|
color: colors.warning,
|
|
},
|
|
emptyBlock: {
|
|
borderTopWidth: 1,
|
|
borderBottomWidth: 1,
|
|
borderColor: colors.line,
|
|
paddingVertical: spacing.xl,
|
|
minHeight: 260,
|
|
justifyContent: 'center',
|
|
},
|
|
eyebrow: {
|
|
...typography.label,
|
|
color: colors.action,
|
|
},
|
|
emptyTitle: {
|
|
color: colors.text,
|
|
fontSize: 44,
|
|
lineHeight: 45,
|
|
fontWeight: '900',
|
|
letterSpacing: -2.6,
|
|
marginTop: spacing.sm,
|
|
marginBottom: spacing.md,
|
|
},
|
|
copy: {
|
|
...typography.body,
|
|
color: colors.textMuted,
|
|
flex: 1,
|
|
},
|
|
archiveHeader: {
|
|
borderTopWidth: 0,
|
|
borderBottomWidth: 1,
|
|
borderColor: colors.line,
|
|
paddingVertical: spacing.md,
|
|
},
|
|
archiveNumber: {
|
|
...typography.display,
|
|
color: colors.text,
|
|
fontSize: 82,
|
|
lineHeight: 84,
|
|
marginTop: spacing.xs,
|
|
},
|
|
archiveLabel: {
|
|
...typography.label,
|
|
color: colors.textMuted,
|
|
},
|
|
timeline: {
|
|
gap: 0,
|
|
},
|
|
timelineNode: {
|
|
borderTopWidth: 1,
|
|
borderTopColor: colors.line,
|
|
},
|
|
sessionStrip: {
|
|
flexDirection: 'row',
|
|
gap: spacing.md,
|
|
paddingVertical: spacing.md,
|
|
alignItems: 'center',
|
|
},
|
|
pressed: {
|
|
opacity: 0.72,
|
|
},
|
|
dateBlock: {
|
|
width: 68,
|
|
borderRightWidth: 1,
|
|
borderRightColor: colors.line,
|
|
paddingRight: spacing.sm,
|
|
},
|
|
sessionDay: {
|
|
color: colors.warning,
|
|
fontSize: 20,
|
|
lineHeight: 23,
|
|
fontWeight: '900',
|
|
letterSpacing: -0.8,
|
|
},
|
|
sessionHour: {
|
|
...typography.label,
|
|
color: colors.textDim,
|
|
marginTop: spacing.xs,
|
|
},
|
|
sessionBody: {
|
|
flex: 1,
|
|
},
|
|
sessionTitle: {
|
|
color: colors.text,
|
|
fontSize: 22,
|
|
lineHeight: 25,
|
|
fontWeight: '900',
|
|
letterSpacing: -1,
|
|
},
|
|
sessionMeta: {
|
|
...typography.body,
|
|
color: colors.textMuted,
|
|
marginTop: spacing.xxs,
|
|
},
|
|
metricLine: {
|
|
flexDirection: 'row',
|
|
gap: spacing.md,
|
|
marginTop: spacing.sm,
|
|
},
|
|
metricText: {
|
|
...typography.label,
|
|
color: colors.textMuted,
|
|
},
|
|
metricTextHot: {
|
|
...typography.label,
|
|
color: colors.action,
|
|
},
|
|
rangePanel: {
|
|
gap: spacing.md,
|
|
},
|
|
rangeGrid: {
|
|
flexDirection: 'row',
|
|
gap: spacing.sm,
|
|
},
|
|
rangeInputBlock: {
|
|
flex: 1,
|
|
gap: spacing.xs,
|
|
},
|
|
rangeInputLabel: {
|
|
...typography.label,
|
|
color: colors.warning,
|
|
},
|
|
rangeInput: {
|
|
minHeight: 44,
|
|
borderWidth: 1,
|
|
borderColor: colors.line,
|
|
color: colors.text,
|
|
paddingHorizontal: spacing.sm,
|
|
fontSize: 14,
|
|
fontWeight: '800',
|
|
},
|
|
rangeWarning: {
|
|
...typography.body,
|
|
color: colors.warning,
|
|
},
|
|
rangeResult: {
|
|
gap: spacing.md,
|
|
},
|
|
detailPanel: {
|
|
paddingBottom: spacing.lg,
|
|
gap: spacing.md,
|
|
},
|
|
loadingLine: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.sm,
|
|
},
|
|
detailRail: {
|
|
borderTopWidth: 1,
|
|
borderBottomWidth: 0,
|
|
borderColor: colors.line,
|
|
flexDirection: 'row',
|
|
},
|
|
detailMetric: {
|
|
flex: 1,
|
|
paddingVertical: spacing.sm,
|
|
},
|
|
detailValue: {
|
|
color: colors.text,
|
|
fontSize: 27,
|
|
lineHeight: 30,
|
|
fontWeight: '900',
|
|
letterSpacing: -1.2,
|
|
},
|
|
detailLabel: {
|
|
...typography.label,
|
|
color: colors.textMuted,
|
|
marginTop: spacing.xs,
|
|
},
|
|
zoneStack: {
|
|
gap: spacing.xs,
|
|
},
|
|
zoneRow: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
gap: spacing.md,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: colors.line,
|
|
paddingBottom: spacing.xs,
|
|
},
|
|
zoneLabel: {
|
|
...typography.label,
|
|
color: colors.text,
|
|
},
|
|
zoneValue: {
|
|
...typography.body,
|
|
color: colors.textMuted,
|
|
},
|
|
});
|