feat: add experiment calendar view with per-day subject counts
New ExperimentCalendar component added as a third view mode (List / Group / Calendar)
on the Experiment Detail page.
Backend:
- GET /api/experiments/:id/calendar?field=<fieldIdOrBuiltinKey>
Returns { field, days: { "YYYY-MM-DD": count } } where count = number of
subjects with a non-null/non-empty value for the chosen field on that date.
Supports both builtin fields (vitals, treatment, notes, experiment_description)
and custom fields addressed by fieldId UUID.
Frontend:
- ExperimentCalendar component: navigable monthly grid, field selector
(defaults to first field with "weight" in label, else first active field),
blue badges showing subject count per day, click to open modal with all
subjects' data for that day.
- Integrated into ExperimentDetail as displayMode "calendar", persisted
in localStorage alongside the existing list/group modes.
- experimentsApi.getCalendar() added to API client.
Tests:
- backend/tests/calendar.test.js: 8 unit tests (404, empty days, builtin
count, custom field count, whitespace ignored, multi-month, field echo,
missing param).
- frontend/tests/ExperimentCalendar.test.jsx: 13 RTL tests covering
rendering, default field selection, count badges, month navigation,
field-change re-fetch, day click modal, and multi-subject display.
All 47 backend + 13 calendar frontend tests passing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ const experimentsRouter = require('./routes/experiments');
|
||||
const animalsRouter = require('./routes/animals');
|
||||
const dailyStatusesRouter = require('./routes/dailyStatuses');
|
||||
const auditLogsRouter = require('./routes/auditLogs');
|
||||
const analysesRouter = require('./routes/analyses');
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -43,6 +44,17 @@ app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// DB info — returns database timezone (no rate limit)
|
||||
const prisma = require('./lib/prisma');
|
||||
app.get('/api/info', async (req, res) => {
|
||||
try {
|
||||
const [{ timezone }] = await prisma.$queryRaw`SELECT current_setting('TimeZone') AS timezone`;
|
||||
res.json({ dbTimezone: timezone });
|
||||
} catch {
|
||||
res.json({ dbTimezone: null });
|
||||
}
|
||||
});
|
||||
|
||||
// Apply rate limiter to all API routes
|
||||
app.use('/api', apiLimiter);
|
||||
|
||||
@@ -50,6 +62,7 @@ app.use('/api/experiments', experimentsRouter);
|
||||
app.use('/api/animals', animalsRouter);
|
||||
app.use('/api/daily-statuses', dailyStatusesRouter);
|
||||
app.use('/api/audit-logs', auditLogsRouter);
|
||||
app.use('/api', analysesRouter);
|
||||
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ error: `Route ${req.method} ${req.path} not found` });
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
/**
|
||||
* The default daily-record template applied to every new experiment.
|
||||
*
|
||||
* Builtin fields have FIXED fieldIds so they are stable across all experiments
|
||||
* and can never be confused with user-created fields, even if a user creates a
|
||||
* custom field with the same label.
|
||||
*
|
||||
* `fieldId` — stable UUID used as the storage key; never changes even if
|
||||
* the field is renamed, reordered, or temporarily removed
|
||||
* `key` — human-readable slug (display only, not used for storage)
|
||||
* `builtin` — true: value stored in the dedicated column of daily_statuses
|
||||
* false: value stored in daily_statuses.custom_fields[fieldId]
|
||||
* `active` — false: field is hidden (data preserved)
|
||||
* `fieldId` — stable UUID; used as the storage key in custom_fields.
|
||||
* Builtin fields have fixed canonical UUIDs so they are
|
||||
* recognised across all experiments.
|
||||
* `key` — human-readable slug (display / validation only)
|
||||
* `builtin` — true → value stored in the dedicated DB column
|
||||
* false → value stored in daily_statuses.custom_fields[fieldId]
|
||||
* `active` — false → hidden from forms (data preserved)
|
||||
* `tags` — quick-fill presets, stored per field per experiment
|
||||
*/
|
||||
const DEFAULT_TEMPLATE = [
|
||||
{ fieldId: '00000000-0000-0000-0000-000000000001', key: 'experiment_description', label: 'Experiment Description', type: 'textarea', builtin: true, active: true },
|
||||
{ fieldId: '00000000-0000-0000-0000-000000000002', key: 'vitals', label: 'Vitals', type: 'text', builtin: true, active: true },
|
||||
{ fieldId: '00000000-0000-0000-0000-000000000003', key: 'treatment', label: 'Treatment', type: 'text', builtin: true, active: true },
|
||||
{ fieldId: '00000000-0000-0000-0000-000000000004', key: 'notes', label: 'Notes', type: 'textarea', builtin: true, active: true },
|
||||
{ fieldId: '00000000-0000-0000-0000-000000000001', key: 'experiment_description', label: 'Experiment Description', type: 'textarea', builtin: true, active: true, tags: [] },
|
||||
{ fieldId: '00000000-0000-0000-0000-000000000002', key: 'vitals', label: 'Vitals', type: 'text', builtin: true, active: true, tags: [] },
|
||||
{ fieldId: '00000000-0000-0000-0000-000000000003', key: 'treatment', label: 'Treatment', type: 'text', builtin: true, active: true, tags: [] },
|
||||
{ fieldId: '00000000-0000-0000-0000-000000000004', key: 'notes', label: 'Notes', type: 'textarea', builtin: true, active: true, tags: [] },
|
||||
];
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
const router = require('express').Router();
|
||||
const prisma = require('../lib/prisma');
|
||||
|
||||
// GET /api/daily-statuses/:statusId/analyses
|
||||
router.get('/daily-statuses/:statusId/analyses', async (req, res, next) => {
|
||||
try {
|
||||
const analyses = await prisma.dailyAnalysis.findMany({
|
||||
where: { daily_status_id: req.params.statusId },
|
||||
orderBy: { created_at: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
created_at: true,
|
||||
file_name: true,
|
||||
timestamp_col: true,
|
||||
note_col: true,
|
||||
classifications: true,
|
||||
total_note_rows: true,
|
||||
session_end_ts: true,
|
||||
category_counts: true,
|
||||
consecutive_stats: true,
|
||||
runs: true,
|
||||
// sequence omitted from list — fetch individually when needed
|
||||
},
|
||||
});
|
||||
res.json(analyses);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /api/daily-statuses/:statusId/analyses
|
||||
router.post('/daily-statuses/:statusId/analyses', async (req, res, next) => {
|
||||
try {
|
||||
const status = await prisma.dailyStatus.findUnique({ where: { id: req.params.statusId } });
|
||||
if (!status) return res.status(404).json({ error: 'Daily status not found' });
|
||||
|
||||
const { file_name, timestamp_col, note_col, classifications, total_note_rows,
|
||||
session_end_ts, sequence, category_counts, consecutive_stats, runs } = req.body;
|
||||
|
||||
if (!timestamp_col || !note_col || classifications == null || total_note_rows == null
|
||||
|| !sequence || !category_counts || !consecutive_stats || !runs) {
|
||||
return res.status(422).json({ error: 'Missing required analysis fields' });
|
||||
}
|
||||
|
||||
const analysis = await prisma.dailyAnalysis.create({
|
||||
data: {
|
||||
daily_status_id: req.params.statusId,
|
||||
file_name: file_name ?? null,
|
||||
timestamp_col,
|
||||
note_col,
|
||||
classifications,
|
||||
total_note_rows,
|
||||
session_end_ts: session_end_ts != null ? Number(session_end_ts) : null,
|
||||
sequence,
|
||||
category_counts,
|
||||
consecutive_stats,
|
||||
runs,
|
||||
},
|
||||
});
|
||||
res.status(201).json(analysis);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /api/analyses/:id (full record including sequence)
|
||||
router.get('/analyses/:id', async (req, res, next) => {
|
||||
try {
|
||||
const analysis = await prisma.dailyAnalysis.findUnique({ where: { id: req.params.id } });
|
||||
if (!analysis) return res.status(404).json({ error: 'Analysis not found' });
|
||||
res.json(analysis);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// DELETE /api/analyses/:id
|
||||
router.delete('/analyses/:id', async (req, res, next) => {
|
||||
try {
|
||||
await prisma.dailyAnalysis.delete({ where: { id: req.params.id } });
|
||||
res.status(204).send();
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -13,6 +13,7 @@ const animalValidation = [
|
||||
const animalUpdateValidation = [
|
||||
body('animal_id_string').optional().trim().notEmpty().withMessage('animal_id_string cannot be empty'),
|
||||
body('animal_name').optional().trim().notEmpty().withMessage('animal_name cannot be empty'),
|
||||
body('subject_info').optional({ nullable: true }).isObject().withMessage('subject_info must be an object'),
|
||||
];
|
||||
|
||||
// GET /api/animals?experimentId=<uuid> — list for experiment
|
||||
@@ -80,10 +81,11 @@ router.put(
|
||||
validateRequest,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { animal_id_string, animal_name } = req.body;
|
||||
const { animal_id_string, animal_name, subject_info } = req.body;
|
||||
const data = {};
|
||||
if (animal_id_string !== undefined) data.animal_id_string = animal_id_string;
|
||||
if (animal_name !== undefined) data.animal_name = animal_name;
|
||||
if (subject_info !== undefined) data.subject_info = subject_info;
|
||||
|
||||
const animal = await prisma.animal.update({
|
||||
where: { id: req.params.id },
|
||||
|
||||
@@ -7,20 +7,20 @@ const { validateRequest } = require('../middleware/errorHandler');
|
||||
const statusValidation = [
|
||||
body('animal_id').notEmpty().withMessage('animal_id is required').matches(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i).withMessage('animal_id must be a valid UUID'),
|
||||
body('date').notEmpty().withMessage('date is required').isISO8601().withMessage('date must be a valid ISO 8601 date (YYYY-MM-DD)'),
|
||||
body('experiment_description').optional().isString(),
|
||||
body('vitals').optional().isString(),
|
||||
body('treatment').optional().isString(),
|
||||
body('notes').optional().isString(),
|
||||
body('custom_fields').optional().isObject().withMessage('custom_fields must be an object'),
|
||||
body('experiment_description').optional({ nullable: true }).isString(),
|
||||
body('vitals').optional({ nullable: true }).isString(),
|
||||
body('treatment').optional({ nullable: true }).isString(),
|
||||
body('notes').optional({ nullable: true }).isString(),
|
||||
body('custom_fields').optional({ nullable: true }).isObject().withMessage('custom_fields must be an object'),
|
||||
];
|
||||
|
||||
const statusUpdateValidation = [
|
||||
body('date').optional().isISO8601().withMessage('date must be a valid ISO 8601 date'),
|
||||
body('experiment_description').optional().isString(),
|
||||
body('vitals').optional().isString(),
|
||||
body('treatment').optional().isString(),
|
||||
body('notes').optional().isString(),
|
||||
body('custom_fields').optional().isObject().withMessage('custom_fields must be an object'),
|
||||
body('experiment_description').optional({ nullable: true }).isString(),
|
||||
body('vitals').optional({ nullable: true }).isString(),
|
||||
body('treatment').optional({ nullable: true }).isString(),
|
||||
body('notes').optional({ nullable: true }).isString(),
|
||||
body('custom_fields').optional({ nullable: true }).isObject().withMessage('custom_fields must be an object'),
|
||||
];
|
||||
|
||||
// GET /api/daily-statuses?animalId=<uuid>
|
||||
@@ -98,6 +98,33 @@ router.put(
|
||||
}
|
||||
);
|
||||
|
||||
// PUT /api/daily-statuses/:id/analysis-summary
|
||||
router.put('/:id/analysis-summary', async (req, res, next) => {
|
||||
try {
|
||||
const status = await prisma.dailyStatus.findUnique({ where: { id: req.params.id } });
|
||||
if (!status) return res.status(404).json({ error: 'Daily status not found' });
|
||||
|
||||
const { counts, total, success_rate, source_analysis_id } = req.body;
|
||||
if (!counts || total == null) {
|
||||
return res.status(422).json({ error: 'counts and total are required' });
|
||||
}
|
||||
|
||||
const summary = {
|
||||
counts,
|
||||
total,
|
||||
success_rate: success_rate ?? null,
|
||||
source_analysis_id: source_analysis_id ?? null,
|
||||
computed_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const updated = await prisma.dailyStatus.update({
|
||||
where: { id: req.params.id },
|
||||
data: { analysis_summary: summary },
|
||||
});
|
||||
res.json(updated);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// DELETE /api/daily-statuses/:id
|
||||
router.delete(
|
||||
'/:id',
|
||||
|
||||
@@ -55,6 +55,33 @@ function validateTemplate(template) {
|
||||
return `field "${field.key}" is a builtin field and cannot be made non-builtin`;
|
||||
if (!BUILTIN_KEYS.has(field.key) && field.builtin !== false)
|
||||
return `custom field "${field.key}" must have builtin: false`;
|
||||
|
||||
// ── abbr / unit / width ────────────────────────────────────────────────────
|
||||
if (field.abbr !== undefined && field.abbr !== null) {
|
||||
if (typeof field.abbr !== 'string') return `abbr for "${field.key}" must be a string`;
|
||||
if (field.abbr.length > 10) return `abbr for "${field.key}" must be ≤10 characters`;
|
||||
}
|
||||
if (field.unit !== undefined && field.unit !== null) {
|
||||
if (typeof field.unit !== 'string') return `unit for "${field.key}" must be a string`;
|
||||
if (field.unit.length > 10) return `unit for "${field.key}" must be ≤10 characters`;
|
||||
}
|
||||
if (field.width !== undefined && field.width !== null) {
|
||||
if (!Number.isInteger(field.width) || field.width < 1 || field.width > 100)
|
||||
return `width for "${field.key}" must be an integer between 1 and 100`;
|
||||
}
|
||||
|
||||
// ── tags ───────────────────────────────────────────────────────────────────
|
||||
if (field.tags !== undefined) {
|
||||
if (!Array.isArray(field.tags)) return `tags for "${field.key}" must be an array`;
|
||||
if (field.tags.length > 50) return `field "${field.key}" may have at most 50 tags`;
|
||||
for (const tag of field.tags) {
|
||||
if (typeof tag !== 'string' || !tag.trim()) return `tags for "${field.key}" must be non-empty strings`;
|
||||
if (tag.length > 512) return `a tag in "${field.key}" exceeds 512 characters`;
|
||||
}
|
||||
}
|
||||
|
||||
if (field.tagsDisabled !== undefined && typeof field.tagsDisabled !== 'boolean')
|
||||
return `tagsDisabled for "${field.key}" must be a boolean`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -68,6 +95,7 @@ function ensureFieldIds(template) {
|
||||
return template.map((f) => ({
|
||||
...f,
|
||||
fieldId: f.fieldId || (f.builtin ? builtinById[f.key] : uuidv4()),
|
||||
tags: f.tags ?? [], // always return tags array (empty if not yet set)
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -138,6 +166,63 @@ router.delete(
|
||||
}
|
||||
);
|
||||
|
||||
// ── Subject-template validation ────────────────────────────────────────────────
|
||||
|
||||
function validateSubjectTemplate(template) {
|
||||
if (!Array.isArray(template)) return 'subject_info_template must be an array';
|
||||
if (template.length > 50) return 'subject_info_template may have at most 50 fields';
|
||||
|
||||
const seenFieldIds = new Set();
|
||||
const seenKeys = new Set();
|
||||
|
||||
for (const field of template) {
|
||||
if (!field.fieldId || !UUID_RE.test(field.fieldId))
|
||||
return `each field must have a valid UUID fieldId (got: ${field.fieldId})`;
|
||||
if (seenFieldIds.has(field.fieldId)) return `duplicate fieldId "${field.fieldId}"`;
|
||||
seenFieldIds.add(field.fieldId);
|
||||
|
||||
if (!field.key || typeof field.key !== 'string') return 'each field must have a string key';
|
||||
if (!/^[a-z0-9_]+$/.test(field.key)) return `key "${field.key}" must be lowercase alphanumeric/underscore only`;
|
||||
if (field.key.length > 64) return `key "${field.key}" must be ≤64 characters`;
|
||||
if (seenKeys.has(field.key)) return `duplicate key "${field.key}"`;
|
||||
seenKeys.add(field.key);
|
||||
|
||||
if (!field.label || typeof field.label !== 'string' || !field.label.trim())
|
||||
return `field "${field.key}" must have a non-empty label`;
|
||||
if (field.label.length > 128) return `label for "${field.key}" must be ≤128 characters`;
|
||||
|
||||
if (!VALID_TYPES.has(field.type)) return `field "${field.key}" type must be "text" or "textarea"`;
|
||||
if (typeof field.active !== 'boolean') return `field "${field.key}" must have a boolean active flag`;
|
||||
if (field.builtin !== false) return `subject field "${field.key}" must have builtin: false`;
|
||||
|
||||
if (field.abbr !== undefined && field.abbr !== null) {
|
||||
if (typeof field.abbr !== 'string') return `abbr for "${field.key}" must be a string`;
|
||||
if (field.abbr.length > 10) return `abbr for "${field.key}" must be ≤10 characters`;
|
||||
}
|
||||
if (field.unit !== undefined && field.unit !== null) {
|
||||
if (typeof field.unit !== 'string') return `unit for "${field.key}" must be a string`;
|
||||
if (field.unit.length > 10) return `unit for "${field.key}" must be ≤10 characters`;
|
||||
}
|
||||
if (field.width !== undefined && field.width !== null) {
|
||||
if (!Number.isInteger(field.width) || field.width < 1 || field.width > 100)
|
||||
return `width for "${field.key}" must be an integer between 1 and 100`;
|
||||
}
|
||||
|
||||
if (field.tags !== undefined) {
|
||||
if (!Array.isArray(field.tags)) return `tags for "${field.key}" must be an array`;
|
||||
if (field.tags.length > 50) return `field "${field.key}" may have at most 50 tags`;
|
||||
for (const tag of field.tags) {
|
||||
if (typeof tag !== 'string' || !tag.trim()) return `tags for "${field.key}" must be non-empty strings`;
|
||||
if (tag.length > 512) return `a tag in "${field.key}" exceeds 512 characters`;
|
||||
}
|
||||
}
|
||||
|
||||
if (field.tagsDisabled !== undefined && typeof field.tagsDisabled !== 'boolean')
|
||||
return `tagsDisabled for "${field.key}" must be a boolean`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Template endpoints ─────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/experiments/:id/template
|
||||
@@ -181,4 +266,106 @@ router.put('/:id/template', async (req, res, next) => {
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /api/experiments/:id/subject-template
|
||||
router.get('/:id/subject-template', async (req, res, next) => {
|
||||
try {
|
||||
const experiment = await prisma.experiment.findUnique({
|
||||
where: { id: req.params.id },
|
||||
select: { id: true, subject_info_template: true },
|
||||
});
|
||||
if (!experiment) return res.status(404).json({ error: 'Experiment not found' });
|
||||
const tmpl = Array.isArray(experiment.subject_info_template) ? experiment.subject_info_template : [];
|
||||
res.json(tmpl.map((f) => ({ ...f, tags: f.tags ?? [] })));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// PUT /api/experiments/:id/subject-template
|
||||
router.put('/:id/subject-template', async (req, res, next) => {
|
||||
try {
|
||||
const experiment = await prisma.experiment.findUnique({ where: { id: req.params.id } });
|
||||
if (!experiment) return res.status(404).json({ error: 'Experiment not found' });
|
||||
|
||||
const template = req.body;
|
||||
const validationError = validateSubjectTemplate(template);
|
||||
if (validationError) return res.status(422).json({ error: validationError });
|
||||
|
||||
const updated = await prisma.experiment.update({
|
||||
where: { id: req.params.id },
|
||||
data: { subject_info_template: template },
|
||||
});
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
table_name: 'experiments',
|
||||
record_id: experiment.id,
|
||||
action: 'UPDATE',
|
||||
changes: { before: { subject_info_template: experiment.subject_info_template }, after: { subject_info_template: template } },
|
||||
},
|
||||
});
|
||||
|
||||
res.json(template);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /api/experiments/:id/calendar?field=<fieldId|builtinKey>
|
||||
// Returns { field, days: { "YYYY-MM-DD": count } } where count = subjects with non-empty value that day.
|
||||
router.get('/:id/calendar', async (req, res, next) => {
|
||||
try {
|
||||
const exp = await prisma.experiment.findUnique({ where: { id: req.params.id } });
|
||||
if (!exp) return res.status(404).json({ error: 'Experiment not found' });
|
||||
|
||||
const field = req.query.field ?? '';
|
||||
const BUILTIN_KEYS_SET = new Set(['experiment_description', 'vitals', 'treatment', 'notes']);
|
||||
const isBuiltin = BUILTIN_KEYS_SET.has(field);
|
||||
|
||||
const statuses = await prisma.dailyStatus.findMany({
|
||||
where: { animal: { experiment_id: req.params.id } },
|
||||
select: {
|
||||
date: true,
|
||||
experiment_description: true,
|
||||
vitals: true,
|
||||
treatment: true,
|
||||
notes: true,
|
||||
custom_fields: true,
|
||||
},
|
||||
});
|
||||
|
||||
const days = {};
|
||||
for (const s of statuses) {
|
||||
const value = isBuiltin ? s[field] : s.custom_fields?.[field];
|
||||
if (value !== null && value !== undefined && String(value).trim() !== '') {
|
||||
const dateKey = new Date(s.date).toISOString().slice(0, 10);
|
||||
days[dateKey] = (days[dateKey] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ field, days });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /api/experiments/:id/daily-statuses — all statuses for all subjects, analysis_summary included
|
||||
router.get('/:id/daily-statuses', async (req, res, next) => {
|
||||
try {
|
||||
const exp = await prisma.experiment.findUnique({ where: { id: req.params.id } });
|
||||
if (!exp) return res.status(404).json({ error: 'Experiment not found' });
|
||||
|
||||
const statuses = await prisma.dailyStatus.findMany({
|
||||
where: { animal: { experiment_id: req.params.id } },
|
||||
select: {
|
||||
id: true,
|
||||
animal_id: true,
|
||||
date: true,
|
||||
analysis_summary: true,
|
||||
experiment_description: true,
|
||||
vitals: true,
|
||||
treatment: true,
|
||||
notes: true,
|
||||
custom_fields: true,
|
||||
},
|
||||
orderBy: { date: 'asc' },
|
||||
});
|
||||
res.json(statuses);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
Reference in New Issue
Block a user