feat: 添加原始睡眠数据列表,优化睡眠详情数据处理逻辑,确保完整的睡眠周期计算
This commit is contained in:
@@ -736,6 +736,109 @@ export default function SleepDetailScreen() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Raw Sleep Samples List - 显示所有原始睡眠数据 */}
|
||||||
|
{sleepData && sleepData.rawSleepSamples && sleepData.rawSleepSamples.length > 0 && (
|
||||||
|
<View style={[styles.rawSamplesContainer, { backgroundColor: colorTokens.background }]}>
|
||||||
|
<View style={styles.rawSamplesHeader}>
|
||||||
|
<Text style={[styles.rawSamplesTitle, { color: colorTokens.text }]}>
|
||||||
|
原始睡眠数据 ({sleepData.rawSleepSamples.length} 条记录)
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.rawSamplesSubtitle, { color: colorTokens.textSecondary }]}>
|
||||||
|
查看数据间隔和可能的gap
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView style={styles.rawSamplesScrollView} nestedScrollEnabled={true}>
|
||||||
|
{sleepData.rawSleepSamples.map((sample, index) => {
|
||||||
|
// 计算与前一个样本的时间间隔
|
||||||
|
const prevSample = index > 0 ? sleepData.rawSleepSamples[index - 1] : null;
|
||||||
|
let gapMinutes = 0;
|
||||||
|
let hasGap = false;
|
||||||
|
|
||||||
|
if (prevSample) {
|
||||||
|
const prevEndTime = new Date(prevSample.endDate).getTime();
|
||||||
|
const currentStartTime = new Date(sample.startDate).getTime();
|
||||||
|
gapMinutes = (currentStartTime - prevEndTime) / (1000 * 60);
|
||||||
|
hasGap = gapMinutes > 1; // 大于1分钟视为有间隔
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = formatTime(sample.startDate);
|
||||||
|
const endTime = formatTime(sample.endDate);
|
||||||
|
const duration = Math.round((new Date(sample.endDate).getTime() - new Date(sample.startDate).getTime()) / (1000 * 60));
|
||||||
|
|
||||||
|
// 获取睡眠阶段中文名称
|
||||||
|
const getStageName = (value: string) => {
|
||||||
|
switch (value) {
|
||||||
|
case 'HKCategoryValueSleepAnalysisInBed': return '在床上';
|
||||||
|
case 'HKCategoryValueSleepAnalysisAwake': return '清醒';
|
||||||
|
case 'HKCategoryValueSleepAnalysisAsleepCore': return '核心睡眠';
|
||||||
|
case 'HKCategoryValueSleepAnalysisAsleepDeep': return '深度睡眠';
|
||||||
|
case 'HKCategoryValueSleepAnalysisAsleepREM': return 'REM睡眠';
|
||||||
|
case 'HKCategoryValueSleepAnalysisAsleepUnspecified': return '未指定睡眠';
|
||||||
|
default: return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取状态颜色
|
||||||
|
const getStageColor = (value: string) => {
|
||||||
|
switch (value) {
|
||||||
|
case 'HKCategoryValueSleepAnalysisInBed': return '#9CA3AF';
|
||||||
|
case 'HKCategoryValueSleepAnalysisAwake': return '#F59E0B';
|
||||||
|
case 'HKCategoryValueSleepAnalysisAsleepCore': return '#8B5CF6';
|
||||||
|
case 'HKCategoryValueSleepAnalysisAsleepDeep': return '#3B82F6';
|
||||||
|
case 'HKCategoryValueSleepAnalysisAsleepREM': return '#EC4899';
|
||||||
|
case 'HKCategoryValueSleepAnalysisAsleepUnspecified': return '#6B7280';
|
||||||
|
default: return '#6B7280';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={index}>
|
||||||
|
{/* 显示数据间隔 */}
|
||||||
|
{hasGap && (
|
||||||
|
<View style={[styles.gapIndicator, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
||||||
|
<Ionicons name="alert-circle-outline" size={14} color="#F59E0B" />
|
||||||
|
<Text style={[styles.gapText, { color: '#F59E0B' }]}>
|
||||||
|
数据间隔: {Math.round(gapMinutes)}分钟
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 睡眠样本条目 */}
|
||||||
|
<View style={[styles.rawSampleItem, { borderColor: colorTokens.border }]}>
|
||||||
|
<View style={styles.sampleHeader}>
|
||||||
|
<View style={styles.sampleLeft}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.stageDot,
|
||||||
|
{ backgroundColor: getStageColor(sample.value) }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Text style={[styles.sampleStage, { color: colorTokens.text }]}>
|
||||||
|
{getStageName(sample.value)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.sampleDuration, { color: colorTokens.textSecondary }]}>
|
||||||
|
{duration}分钟
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.sampleTimeRange}>
|
||||||
|
<Text style={[styles.sampleTime, { color: colorTokens.textSecondary }]}>
|
||||||
|
{startTime} - {endTime}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.sampleIndex, { color: colorTokens.textMuted }]}>
|
||||||
|
#{index + 1}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
{infoModal.type && (
|
{infoModal.type && (
|
||||||
@@ -1388,4 +1491,92 @@ const styles = StyleSheet.create({
|
|||||||
lineHeight: 16,
|
lineHeight: 16,
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
},
|
},
|
||||||
|
// Raw Sleep Samples List 样式
|
||||||
|
rawSamplesContainer: {
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 24,
|
||||||
|
marginHorizontal: 4,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
rawSamplesHeader: {
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
rawSamplesTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
rawSamplesSubtitle: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
rawSamplesScrollView: {
|
||||||
|
maxHeight: 400, // 限制高度,避免列表过长
|
||||||
|
},
|
||||||
|
rawSampleItem: {
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderLeftWidth: 3,
|
||||||
|
borderLeftColor: 'transparent',
|
||||||
|
marginBottom: 8,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: 'rgba(248, 250, 252, 0.5)',
|
||||||
|
},
|
||||||
|
sampleHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
sampleLeft: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
stageDot: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
sampleStage: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
sampleDuration: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
sampleTimeRange: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
sampleTime: {
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
sampleIndex: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
gapIndicator: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
marginVertical: 4,
|
||||||
|
borderRadius: 8,
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
gapText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
28
package-lock.json
generated
28
package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^14.1.0",
|
"@expo/vector-icons": "^14.1.0",
|
||||||
|
"@kingstinct/react-native-healthkit": "^10.1.0",
|
||||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||||
"@react-native-community/datetimepicker": "^8.4.4",
|
"@react-native-community/datetimepicker": "^8.4.4",
|
||||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||||
@@ -55,6 +56,7 @@
|
|||||||
"react-native-image-viewing": "^0.2.2",
|
"react-native-image-viewing": "^0.2.2",
|
||||||
"react-native-markdown-display": "^7.0.2",
|
"react-native-markdown-display": "^7.0.2",
|
||||||
"react-native-modal-datetime-picker": "^18.0.0",
|
"react-native-modal-datetime-picker": "^18.0.0",
|
||||||
|
"react-native-nitro-modules": "^0.29.3",
|
||||||
"react-native-popover-view": "^6.1.0",
|
"react-native-popover-view": "^6.1.0",
|
||||||
"react-native-purchases": "^9.2.2",
|
"react-native-purchases": "^9.2.2",
|
||||||
"react-native-reanimated": "~3.17.4",
|
"react-native-reanimated": "~3.17.4",
|
||||||
@@ -2728,6 +2730,21 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@kingstinct/react-native-healthkit": {
|
||||||
|
"version": "10.1.0",
|
||||||
|
"resolved": "https://mirrors.tencent.com/npm/@kingstinct/react-native-healthkit/-/react-native-healthkit-10.1.0.tgz",
|
||||||
|
"integrity": "sha512-p6f3Uf4p6GXs+8xIc5NHu8DPnNJC9kxGvI+4qmgGk5U24hVZBZFAwFT53jkQMoIHZIoQmtuXJDp8jMJ7WzeZ+Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/kingstinct"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*",
|
||||||
|
"react-native-nitro-modules": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "0.2.12",
|
"version": "0.2.12",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||||
@@ -11732,6 +11749,17 @@
|
|||||||
"react-native": ">=0.65.0"
|
"react-native": ">=0.65.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-native-nitro-modules": {
|
||||||
|
"version": "0.29.3",
|
||||||
|
"resolved": "https://mirrors.tencent.com/npm/react-native-nitro-modules/-/react-native-nitro-modules-0.29.3.tgz",
|
||||||
|
"integrity": "sha512-gGaCueHKaZSw2rlrKrPgMZE6O6qvsnTJwNysJgk4ZEHMwnVe6Auk5hc4+sJPQLOVd6o+HMHdVhVQhZZv1u19Eg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-native-popover-view": {
|
"node_modules/react-native-popover-view": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://mirrors.tencent.com/npm/react-native-popover-view/-/react-native-popover-view-6.1.0.tgz",
|
"resolved": "https://mirrors.tencent.com/npm/react-native-popover-view/-/react-native-popover-view-6.1.0.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^14.1.0",
|
"@expo/vector-icons": "^14.1.0",
|
||||||
|
"@kingstinct/react-native-healthkit": "^10.1.0",
|
||||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||||
"@react-native-community/datetimepicker": "^8.4.4",
|
"@react-native-community/datetimepicker": "^8.4.4",
|
||||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"expo": "53.0.22",
|
"expo": "53.0.22",
|
||||||
"expo-apple-authentication": "~7.2.4",
|
"expo-apple-authentication": "~7.2.4",
|
||||||
|
"expo-background-task": "~0.2.8",
|
||||||
"expo-blur": "~14.1.5",
|
"expo-blur": "~14.1.5",
|
||||||
"expo-camera": "^16.1.11",
|
"expo-camera": "^16.1.11",
|
||||||
"expo-constants": "~17.1.7",
|
"expo-constants": "~17.1.7",
|
||||||
@@ -58,6 +60,7 @@
|
|||||||
"react-native-image-viewing": "^0.2.2",
|
"react-native-image-viewing": "^0.2.2",
|
||||||
"react-native-markdown-display": "^7.0.2",
|
"react-native-markdown-display": "^7.0.2",
|
||||||
"react-native-modal-datetime-picker": "^18.0.0",
|
"react-native-modal-datetime-picker": "^18.0.0",
|
||||||
|
"react-native-nitro-modules": "^0.29.3",
|
||||||
"react-native-popover-view": "^6.1.0",
|
"react-native-popover-view": "^6.1.0",
|
||||||
"react-native-purchases": "^9.2.2",
|
"react-native-purchases": "^9.2.2",
|
||||||
"react-native-reanimated": "~3.17.4",
|
"react-native-reanimated": "~3.17.4",
|
||||||
@@ -69,8 +72,7 @@
|
|||||||
"react-native-web": "~0.20.0",
|
"react-native-web": "~0.20.0",
|
||||||
"react-native-webview": "13.13.5",
|
"react-native-webview": "13.13.5",
|
||||||
"react-native-wheel-picker-expo": "^0.5.4",
|
"react-native-wheel-picker-expo": "^0.5.4",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0"
|
||||||
"expo-background-task": "~0.2.8"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.25.2",
|
"@babel/core": "^7.25.2",
|
||||||
@@ -80,4 +82,4 @@
|
|||||||
"typescript": "~5.8.3"
|
"typescript": "~5.8.3"
|
||||||
},
|
},
|
||||||
"private": true
|
"private": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -320,37 +320,27 @@ export async function fetchSleepDetailForDate(date: Date): Promise<SleepDetailDa
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 找到入睡时间和起床时间
|
// 找到入睡时间和起床时间
|
||||||
// 过滤出实际睡眠阶段(排除在床时间和清醒时间)
|
// 使用所有样本数据来确定完整的睡眠周期(包含清醒时间)
|
||||||
const actualSleepSamples = sleepSamples.filter(sample =>
|
|
||||||
sample.value !== SleepStage.InBed && sample.value !== SleepStage.Awake
|
|
||||||
);
|
|
||||||
|
|
||||||
// 入睡时间:第一个实际睡眠阶段的开始时间
|
|
||||||
// 起床时间:最后一个实际睡眠阶段的结束时间
|
|
||||||
let bedtime: string;
|
let bedtime: string;
|
||||||
let wakeupTime: string;
|
let wakeupTime: string;
|
||||||
|
|
||||||
if (actualSleepSamples.length > 0) {
|
if (sleepSamples.length > 0) {
|
||||||
// 按开始时间排序
|
// 按开始时间排序
|
||||||
const sortedSleepSamples = actualSleepSamples.sort((a, b) =>
|
const sortedSamples = sleepSamples.sort((a, b) =>
|
||||||
new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
|
new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
|
||||||
);
|
);
|
||||||
|
|
||||||
bedtime = sortedSleepSamples[0].startDate;
|
// 入睡时间:第一个样本的开始时间(包含清醒时间,确保完整性)
|
||||||
wakeupTime = sortedSleepSamples[sortedSleepSamples.length - 1].endDate;
|
bedtime = sortedSamples[0].startDate;
|
||||||
|
// 起床时间:最后一个样本的结束时间
|
||||||
|
wakeupTime = sortedSamples[sortedSamples.length - 1].endDate;
|
||||||
|
|
||||||
console.log('计算入睡和起床时间:');
|
console.log('计算入睡和起床时间:');
|
||||||
console.log('- 入睡时间:', dayjs(bedtime).format('YYYY-MM-DD HH:mm:ss'));
|
console.log('- 入睡时间:', dayjs(bedtime).format('YYYY-MM-DD HH:mm:ss'));
|
||||||
console.log('- 起床时间:', dayjs(wakeupTime).format('YYYY-MM-DD HH:mm:ss'));
|
console.log('- 起床时间:', dayjs(wakeupTime).format('YYYY-MM-DD HH:mm:ss'));
|
||||||
} else {
|
} else {
|
||||||
// 如果没有实际睡眠数据,回退到使用所有样本数据
|
console.warn('没有找到睡眠样本数据');
|
||||||
console.warn('没有找到实际睡眠阶段数据,使用所有样本数据');
|
return null;
|
||||||
const sortedAllSamples = sleepSamples.sort((a, b) =>
|
|
||||||
new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
|
|
||||||
);
|
|
||||||
|
|
||||||
bedtime = sortedAllSamples[0].startDate;
|
|
||||||
wakeupTime = sortedAllSamples[sortedAllSamples.length - 1].endDate;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算在床时间 - 使用 INBED 样本数据
|
// 计算在床时间 - 使用 INBED 样本数据
|
||||||
@@ -380,7 +370,6 @@ export async function fetchSleepDetailForDate(date: Date): Promise<SleepDetailDa
|
|||||||
// 计算睡眠阶段统计
|
// 计算睡眠阶段统计
|
||||||
const sleepStages = calculateSleepStageStats(sleepSamples);
|
const sleepStages = calculateSleepStageStats(sleepSamples);
|
||||||
|
|
||||||
// 计算总睡眠时间
|
|
||||||
const totalSleepTime = sleepStages
|
const totalSleepTime = sleepStages
|
||||||
.reduce((total, stage) => total + stage.duration, 0);
|
.reduce((total, stage) => total + stage.duration, 0);
|
||||||
|
|
||||||
@@ -401,6 +390,19 @@ export async function fetchSleepDetailForDate(date: Date): Promise<SleepDetailDa
|
|||||||
// 获取质量描述和建议
|
// 获取质量描述和建议
|
||||||
const qualityInfo = getSleepQualityInfo(sleepScore);
|
const qualityInfo = getSleepQualityInfo(sleepScore);
|
||||||
|
|
||||||
|
// 详细的调试信息
|
||||||
|
console.log('=== 睡眠数据处理结果 ===');
|
||||||
|
console.log('时间范围:', dayjs(bedtime).format('HH:mm'), '-', dayjs(wakeupTime).format('HH:mm'));
|
||||||
|
console.log('在床时间:', timeInBed, '分钟');
|
||||||
|
console.log('总睡眠时间:', totalSleepTime, '分钟');
|
||||||
|
console.log('睡眠效率:', sleepEfficiency, '%');
|
||||||
|
console.log('睡眠阶段统计:');
|
||||||
|
sleepStages.forEach(stage => {
|
||||||
|
console.log(` ${getSleepStageDisplayName(stage.stage)}: ${stage.duration}分钟 (${stage.percentage}%)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('========================');
|
||||||
|
|
||||||
const sleepDetailData: SleepDetailData = {
|
const sleepDetailData: SleepDetailData = {
|
||||||
sleepScore,
|
sleepScore,
|
||||||
totalSleepTime,
|
totalSleepTime,
|
||||||
@@ -417,7 +419,7 @@ export async function fetchSleepDetailForDate(date: Date): Promise<SleepDetailDa
|
|||||||
recommendation: qualityInfo.recommendation
|
recommendation: qualityInfo.recommendation
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('睡眠详情数据获取完成:', sleepDetailData);
|
console.log('睡眠详情数据获取完成,睡眠得分:', sleepScore);
|
||||||
return sleepDetailData;
|
return sleepDetailData;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user