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:
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "experiments" ADD COLUMN "subject_info_template" JSONB NOT NULL DEFAULT '[]';
|
||||||
|
ALTER TABLE "animals" ADD COLUMN "subject_info" JSONB;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
CREATE TABLE "daily_analyses" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"daily_status_id" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"file_name" TEXT,
|
||||||
|
"timestamp_col" TEXT NOT NULL,
|
||||||
|
"note_col" TEXT NOT NULL,
|
||||||
|
"classifications" JSONB NOT NULL,
|
||||||
|
"total_note_rows" INTEGER NOT NULL,
|
||||||
|
"sequence" JSONB NOT NULL,
|
||||||
|
"category_counts" JSONB NOT NULL,
|
||||||
|
"consecutive_stats" JSONB NOT NULL,
|
||||||
|
"runs" JSONB NOT NULL,
|
||||||
|
CONSTRAINT "daily_analyses_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "daily_analyses"
|
||||||
|
ADD CONSTRAINT "daily_analyses_daily_status_id_fkey"
|
||||||
|
FOREIGN KEY ("daily_status_id")
|
||||||
|
REFERENCES "daily_statuses"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "daily_analyses" ADD COLUMN "session_end_ts" DOUBLE PRECISION;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "daily_statuses" ADD COLUMN "analysis_summary" JSONB;
|
||||||
@@ -13,6 +13,7 @@ model Experiment {
|
|||||||
title String
|
title String
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
template Json @default("[]")
|
template Json @default("[]")
|
||||||
|
subject_info_template Json @default("[]")
|
||||||
animals Animal[]
|
animals Animal[]
|
||||||
|
|
||||||
@@map("experiments")
|
@@map("experiments")
|
||||||
@@ -23,6 +24,7 @@ model Animal {
|
|||||||
experiment_id String
|
experiment_id String
|
||||||
animal_id_string String
|
animal_id_string String
|
||||||
animal_name String
|
animal_name String
|
||||||
|
subject_info Json?
|
||||||
experiment Experiment @relation(fields: [experiment_id], references: [id], onDelete: Cascade)
|
experiment Experiment @relation(fields: [experiment_id], references: [id], onDelete: Cascade)
|
||||||
daily_statuses DailyStatus[]
|
daily_statuses DailyStatus[]
|
||||||
|
|
||||||
@@ -38,11 +40,32 @@ model DailyStatus {
|
|||||||
treatment String?
|
treatment String?
|
||||||
notes String?
|
notes String?
|
||||||
custom_fields Json?
|
custom_fields Json?
|
||||||
|
analysis_summary Json?
|
||||||
animal Animal @relation(fields: [animal_id], references: [id], onDelete: Cascade)
|
animal Animal @relation(fields: [animal_id], references: [id], onDelete: Cascade)
|
||||||
|
analyses DailyAnalysis[]
|
||||||
|
|
||||||
@@map("daily_statuses")
|
@@map("daily_statuses")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model DailyAnalysis {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
daily_status_id String
|
||||||
|
daily_status DailyStatus @relation(fields: [daily_status_id], references: [id], onDelete: Cascade)
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
file_name String?
|
||||||
|
timestamp_col String
|
||||||
|
note_col String
|
||||||
|
classifications Json
|
||||||
|
total_note_rows Int
|
||||||
|
session_end_ts Float?
|
||||||
|
sequence Json
|
||||||
|
category_counts Json
|
||||||
|
consecutive_stats Json
|
||||||
|
runs Json
|
||||||
|
|
||||||
|
@@map("daily_analyses")
|
||||||
|
}
|
||||||
|
|
||||||
model AuditLog {
|
model AuditLog {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
table_name String
|
table_name String
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const experimentsRouter = require('./routes/experiments');
|
|||||||
const animalsRouter = require('./routes/animals');
|
const animalsRouter = require('./routes/animals');
|
||||||
const dailyStatusesRouter = require('./routes/dailyStatuses');
|
const dailyStatusesRouter = require('./routes/dailyStatuses');
|
||||||
const auditLogsRouter = require('./routes/auditLogs');
|
const auditLogsRouter = require('./routes/auditLogs');
|
||||||
|
const analysesRouter = require('./routes/analyses');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -43,6 +44,17 @@ app.get('/health', (req, res) => {
|
|||||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
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
|
// Apply rate limiter to all API routes
|
||||||
app.use('/api', apiLimiter);
|
app.use('/api', apiLimiter);
|
||||||
|
|
||||||
@@ -50,6 +62,7 @@ app.use('/api/experiments', experimentsRouter);
|
|||||||
app.use('/api/animals', animalsRouter);
|
app.use('/api/animals', animalsRouter);
|
||||||
app.use('/api/daily-statuses', dailyStatusesRouter);
|
app.use('/api/daily-statuses', dailyStatusesRouter);
|
||||||
app.use('/api/audit-logs', auditLogsRouter);
|
app.use('/api/audit-logs', auditLogsRouter);
|
||||||
|
app.use('/api', analysesRouter);
|
||||||
|
|
||||||
app.use((req, res) => {
|
app.use((req, res) => {
|
||||||
res.status(404).json({ error: `Route ${req.method} ${req.path} not found` });
|
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.
|
* The default daily-record template applied to every new experiment.
|
||||||
*
|
*
|
||||||
* Builtin fields have FIXED fieldIds so they are stable across all experiments
|
* `fieldId` — stable UUID; used as the storage key in custom_fields.
|
||||||
* and can never be confused with user-created fields, even if a user creates a
|
* Builtin fields have fixed canonical UUIDs so they are
|
||||||
* custom field with the same label.
|
* recognised across all experiments.
|
||||||
*
|
* `key` — human-readable slug (display / validation only)
|
||||||
* `fieldId` — stable UUID used as the storage key; never changes even if
|
* `builtin` — true → value stored in the dedicated DB column
|
||||||
* the field is renamed, reordered, or temporarily removed
|
* false → value stored in daily_statuses.custom_fields[fieldId]
|
||||||
* `key` — human-readable slug (display only, not used for storage)
|
* `active` — false → hidden from forms (data preserved)
|
||||||
* `builtin` — true: value stored in the dedicated column of daily_statuses
|
* `tags` — quick-fill presets, stored per field per experiment
|
||||||
* false: value stored in daily_statuses.custom_fields[fieldId]
|
|
||||||
* `active` — false: field is hidden (data preserved)
|
|
||||||
*/
|
*/
|
||||||
const DEFAULT_TEMPLATE = [
|
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-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 },
|
{ 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 },
|
{ 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 },
|
{ 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;
|
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 = [
|
const animalUpdateValidation = [
|
||||||
body('animal_id_string').optional().trim().notEmpty().withMessage('animal_id_string cannot be empty'),
|
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('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
|
// GET /api/animals?experimentId=<uuid> — list for experiment
|
||||||
@@ -80,10 +81,11 @@ router.put(
|
|||||||
validateRequest,
|
validateRequest,
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { animal_id_string, animal_name } = req.body;
|
const { animal_id_string, animal_name, subject_info } = req.body;
|
||||||
const data = {};
|
const data = {};
|
||||||
if (animal_id_string !== undefined) data.animal_id_string = animal_id_string;
|
if (animal_id_string !== undefined) data.animal_id_string = animal_id_string;
|
||||||
if (animal_name !== undefined) data.animal_name = animal_name;
|
if (animal_name !== undefined) data.animal_name = animal_name;
|
||||||
|
if (subject_info !== undefined) data.subject_info = subject_info;
|
||||||
|
|
||||||
const animal = await prisma.animal.update({
|
const animal = await prisma.animal.update({
|
||||||
where: { id: req.params.id },
|
where: { id: req.params.id },
|
||||||
|
|||||||
@@ -7,20 +7,20 @@ const { validateRequest } = require('../middleware/errorHandler');
|
|||||||
const statusValidation = [
|
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('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('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('experiment_description').optional({ nullable: true }).isString(),
|
||||||
body('vitals').optional().isString(),
|
body('vitals').optional({ nullable: true }).isString(),
|
||||||
body('treatment').optional().isString(),
|
body('treatment').optional({ nullable: true }).isString(),
|
||||||
body('notes').optional().isString(),
|
body('notes').optional({ nullable: true }).isString(),
|
||||||
body('custom_fields').optional().isObject().withMessage('custom_fields must be an object'),
|
body('custom_fields').optional({ nullable: true }).isObject().withMessage('custom_fields must be an object'),
|
||||||
];
|
];
|
||||||
|
|
||||||
const statusUpdateValidation = [
|
const statusUpdateValidation = [
|
||||||
body('date').optional().isISO8601().withMessage('date must be a valid ISO 8601 date'),
|
body('date').optional().isISO8601().withMessage('date must be a valid ISO 8601 date'),
|
||||||
body('experiment_description').optional().isString(),
|
body('experiment_description').optional({ nullable: true }).isString(),
|
||||||
body('vitals').optional().isString(),
|
body('vitals').optional({ nullable: true }).isString(),
|
||||||
body('treatment').optional().isString(),
|
body('treatment').optional({ nullable: true }).isString(),
|
||||||
body('notes').optional().isString(),
|
body('notes').optional({ nullable: true }).isString(),
|
||||||
body('custom_fields').optional().isObject().withMessage('custom_fields must be an object'),
|
body('custom_fields').optional({ nullable: true }).isObject().withMessage('custom_fields must be an object'),
|
||||||
];
|
];
|
||||||
|
|
||||||
// GET /api/daily-statuses?animalId=<uuid>
|
// 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
|
// DELETE /api/daily-statuses/:id
|
||||||
router.delete(
|
router.delete(
|
||||||
'/:id',
|
'/:id',
|
||||||
|
|||||||
@@ -55,6 +55,33 @@ function validateTemplate(template) {
|
|||||||
return `field "${field.key}" is a builtin field and cannot be made non-builtin`;
|
return `field "${field.key}" is a builtin field and cannot be made non-builtin`;
|
||||||
if (!BUILTIN_KEYS.has(field.key) && field.builtin !== false)
|
if (!BUILTIN_KEYS.has(field.key) && field.builtin !== false)
|
||||||
return `custom field "${field.key}" must have 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;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -68,6 +95,7 @@ function ensureFieldIds(template) {
|
|||||||
return template.map((f) => ({
|
return template.map((f) => ({
|
||||||
...f,
|
...f,
|
||||||
fieldId: f.fieldId || (f.builtin ? builtinById[f.key] : uuidv4()),
|
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 ─────────────────────────────────────────────────────────
|
// ── Template endpoints ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// GET /api/experiments/:id/template
|
// GET /api/experiments/:id/template
|
||||||
@@ -181,4 +266,106 @@ router.put('/:id/template', async (req, res, next) => {
|
|||||||
} catch (err) { next(err); }
|
} 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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
const request = require('supertest');
|
||||||
|
|
||||||
|
jest.mock('../src/lib/prisma', () => require('./__mocks__/prisma'));
|
||||||
|
const prisma = require('../src/lib/prisma');
|
||||||
|
const app = require('../src/app');
|
||||||
|
|
||||||
|
const EXP_ID = 'aaaaaaaa-0000-0000-0000-000000000001';
|
||||||
|
|
||||||
|
const EXPERIMENT = {
|
||||||
|
id: EXP_ID,
|
||||||
|
title: 'Calendar Test Study',
|
||||||
|
created_at: new Date('2024-01-01').toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper: build a DailyStatus record
|
||||||
|
function makeStatus({ date, vitals = null, customFields = null }) {
|
||||||
|
return {
|
||||||
|
date: new Date(date),
|
||||||
|
experiment_description: null,
|
||||||
|
vitals,
|
||||||
|
treatment: null,
|
||||||
|
notes: null,
|
||||||
|
custom_fields: customFields,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/experiments/:id/calendar', () => {
|
||||||
|
it('returns 404 when experiment does not exist', async () => {
|
||||||
|
prisma.experiment.findUnique.mockResolvedValue(null);
|
||||||
|
const res = await request(app).get(`/api/experiments/${EXP_ID}/calendar?field=vitals`);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.error).toMatch(/not found/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty days object when no statuses exist', async () => {
|
||||||
|
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
|
||||||
|
prisma.dailyStatus.findMany.mockResolvedValue([]);
|
||||||
|
const res = await request(app).get(`/api/experiments/${EXP_ID}/calendar?field=vitals`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.days).toEqual({});
|
||||||
|
expect(res.body.field).toBe('vitals');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts subjects with non-empty builtin field per day', async () => {
|
||||||
|
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
|
||||||
|
prisma.dailyStatus.findMany.mockResolvedValue([
|
||||||
|
makeStatus({ date: '2026-03-10', vitals: '120/80' }),
|
||||||
|
makeStatus({ date: '2026-03-10', vitals: '110/70' }),
|
||||||
|
makeStatus({ date: '2026-03-11', vitals: '115/75' }),
|
||||||
|
makeStatus({ date: '2026-03-12', vitals: null }), // null — not counted
|
||||||
|
makeStatus({ date: '2026-03-12', vitals: '' }), // empty — not counted
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/experiments/${EXP_ID}/calendar?field=vitals`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.days).toEqual({
|
||||||
|
'2026-03-10': 2,
|
||||||
|
'2026-03-11': 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts subjects with non-empty custom field per day', async () => {
|
||||||
|
const FIELD_ID = 'cccccccc-0000-0000-0000-000000000099';
|
||||||
|
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
|
||||||
|
prisma.dailyStatus.findMany.mockResolvedValue([
|
||||||
|
makeStatus({ date: '2026-04-01', customFields: { [FIELD_ID]: '320' } }),
|
||||||
|
makeStatus({ date: '2026-04-01', customFields: { [FIELD_ID]: '310' } }),
|
||||||
|
makeStatus({ date: '2026-04-01', customFields: { [FIELD_ID]: null } }), // null — not counted
|
||||||
|
makeStatus({ date: '2026-04-02', customFields: { [FIELD_ID]: '315' } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/experiments/${EXP_ID}/calendar?field=${FIELD_ID}`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.days).toEqual({
|
||||||
|
'2026-04-01': 2,
|
||||||
|
'2026-04-02': 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores whitespace-only custom field values', async () => {
|
||||||
|
const FIELD_ID = 'dddddddd-0000-0000-0000-000000000099';
|
||||||
|
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
|
||||||
|
prisma.dailyStatus.findMany.mockResolvedValue([
|
||||||
|
makeStatus({ date: '2026-04-05', customFields: { [FIELD_ID]: ' ' } }),
|
||||||
|
makeStatus({ date: '2026-04-05', customFields: { [FIELD_ID]: '280' } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/experiments/${EXP_ID}/calendar?field=${FIELD_ID}`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.days).toEqual({ '2026-04-05': 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns days from multiple months correctly', async () => {
|
||||||
|
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
|
||||||
|
prisma.dailyStatus.findMany.mockResolvedValue([
|
||||||
|
makeStatus({ date: '2026-02-15', vitals: '100/60' }),
|
||||||
|
makeStatus({ date: '2026-03-01', vitals: '105/65' }),
|
||||||
|
makeStatus({ date: '2026-03-01', vitals: '108/68' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/experiments/${EXP_ID}/calendar?field=vitals`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.days['2026-02-15']).toBe(1);
|
||||||
|
expect(res.body.days['2026-03-01']).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns field key in response body', async () => {
|
||||||
|
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
|
||||||
|
prisma.dailyStatus.findMany.mockResolvedValue([]);
|
||||||
|
const res = await request(app).get(`/api/experiments/${EXP_ID}/calendar?field=treatment`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.field).toBe('treatment');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing field param gracefully (empty days)', async () => {
|
||||||
|
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
|
||||||
|
prisma.dailyStatus.findMany.mockResolvedValue([
|
||||||
|
makeStatus({ date: '2026-04-01', vitals: 'present' }),
|
||||||
|
]);
|
||||||
|
// No ?field= → all values undefined, nothing counted
|
||||||
|
const res = await request(app).get(`/api/experiments/${EXP_ID}/calendar`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.days).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,8 @@ services:
|
|||||||
POSTGRES_USER: ${POSTGRES_USER:-expuser}
|
POSTGRES_USER: ${POSTGRES_USER:-expuser}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-exppassword}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-exppassword}
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-experiments_db}
|
POSTGRES_DB: ${POSTGRES_DB:-experiments_db}
|
||||||
|
TZ: America/New_York
|
||||||
|
command: ["postgres", "-c", "timezone=America/New_York"]
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
# Port NOT exposed to host — only reachable by backend via internal Docker network
|
# Port NOT exposed to host — only reachable by backend via internal Docker network
|
||||||
@@ -34,6 +36,7 @@ services:
|
|||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: 3001
|
PORT: 3001
|
||||||
CORS_ORIGIN: "http://localhost:52867"
|
CORS_ORIGIN: "http://localhost:52867"
|
||||||
|
TZ: America/New_York
|
||||||
# Non-root user set in Dockerfile; entrypoint runs migrations then starts server
|
# Non-root user set in Dockerfile; entrypoint runs migrations then starts server
|
||||||
entrypoint: ["/bin/sh", "/app/docker-entrypoint.sh"]
|
entrypoint: ["/bin/sh", "/app/docker-entrypoint.sh"]
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
Generated
+587
-13
@@ -12,7 +12,8 @@
|
|||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.24.0"
|
"react-router-dom": "^6.24.0",
|
||||||
|
"recharts": "^2.12.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.24.7",
|
"@babel/core": "^7.24.7",
|
||||||
@@ -1757,7 +1758,6 @@
|
|||||||
"version": "7.29.2",
|
"version": "7.29.2",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
@@ -2832,6 +2832,60 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||||
|
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
|
||||||
|
},
|
||||||
"node_modules/@types/graceful-fs": {
|
"node_modules/@types/graceful-fs": {
|
||||||
"version": "4.1.9",
|
"version": "4.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
|
||||||
@@ -3598,6 +3652,14 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/clsx": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/co": {
|
"node_modules/co": {
|
||||||
"version": "4.6.0",
|
"version": "4.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
||||||
@@ -3739,9 +3801,117 @@
|
|||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
|
||||||
"dev": true,
|
},
|
||||||
"peer": true
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/date-fns": {
|
"node_modules/date-fns": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
@@ -3775,6 +3945,11 @@
|
|||||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js-light": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="
|
||||||
|
},
|
||||||
"node_modules/dedent": {
|
"node_modules/dedent": {
|
||||||
"version": "1.7.2",
|
"version": "1.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
||||||
@@ -3917,6 +4092,15 @@
|
|||||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/dom-helpers": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.8.7",
|
||||||
|
"csstype": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/domexception": {
|
"node_modules/domexception": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz",
|
||||||
@@ -4156,6 +4340,11 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "4.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||||
|
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
|
||||||
|
},
|
||||||
"node_modules/exit": {
|
"node_modules/exit": {
|
||||||
"version": "0.1.2",
|
"version": "0.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz",
|
||||||
@@ -4181,6 +4370,14 @@
|
|||||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-equals": {
|
||||||
|
"version": "5.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
|
||||||
|
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-glob": {
|
"node_modules/fast-glob": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||||
@@ -4642,6 +4839,14 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-arguments": {
|
"node_modules/is-arguments": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
|
||||||
@@ -6282,6 +6487,11 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash": {
|
||||||
|
"version": "4.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||||
|
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="
|
||||||
|
},
|
||||||
"node_modules/lodash.debounce": {
|
"node_modules/lodash.debounce": {
|
||||||
"version": "4.0.8",
|
"version": "4.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
|
||||||
@@ -6501,7 +6711,6 @@
|
|||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -6950,6 +7159,21 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prop-types": {
|
||||||
|
"version": "15.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
|
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||||
|
"dependencies": {
|
||||||
|
"loose-envify": "^1.4.0",
|
||||||
|
"object-assign": "^4.1.1",
|
||||||
|
"react-is": "^16.13.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prop-types/node_modules/react-is": {
|
||||||
|
"version": "16.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
|
||||||
|
},
|
||||||
"node_modules/proxy-from-env": {
|
"node_modules/proxy-from-env": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||||
@@ -7089,6 +7313,35 @@
|
|||||||
"react-dom": ">=16.8"
|
"react-dom": ">=16.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-smooth": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-equals": "^5.0.1",
|
||||||
|
"prop-types": "^15.8.1",
|
||||||
|
"react-transition-group": "^4.4.5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-transition-group": {
|
||||||
|
"version": "4.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||||
|
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.5.5",
|
||||||
|
"dom-helpers": "^5.0.1",
|
||||||
|
"loose-envify": "^1.4.0",
|
||||||
|
"prop-types": "^15.6.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.6.0",
|
||||||
|
"react-dom": ">=16.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/read-cache": {
|
"node_modules/read-cache": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||||
@@ -7110,6 +7363,41 @@
|
|||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "2.15.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
|
||||||
|
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
|
||||||
|
"dependencies": {
|
||||||
|
"clsx": "^2.0.0",
|
||||||
|
"eventemitter3": "^4.0.1",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"react-is": "^18.3.1",
|
||||||
|
"react-smooth": "^4.0.4",
|
||||||
|
"recharts-scale": "^0.4.4",
|
||||||
|
"tiny-invariant": "^1.3.1",
|
||||||
|
"victory-vendor": "^36.6.8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/recharts-scale": {
|
||||||
|
"version": "0.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
|
||||||
|
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
|
||||||
|
"dependencies": {
|
||||||
|
"decimal.js-light": "^2.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/recharts/node_modules/react-is": {
|
||||||
|
"version": "18.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||||
|
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="
|
||||||
|
},
|
||||||
"node_modules/redent": {
|
"node_modules/redent": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||||
@@ -7758,6 +8046,11 @@
|
|||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-invariant": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.16",
|
"version": "0.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||||
@@ -7969,6 +8262,27 @@
|
|||||||
"node": ">=10.12.0"
|
"node": ">=10.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/victory-vendor": {
|
||||||
|
"version": "36.9.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
|
||||||
|
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "4.5.14",
|
"version": "4.5.14",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.14.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.14.tgz",
|
||||||
@@ -9407,8 +9721,7 @@
|
|||||||
"@babel/runtime": {
|
"@babel/runtime": {
|
||||||
"version": "7.29.2",
|
"version": "7.29.2",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"@babel/template": {
|
"@babel/template": {
|
||||||
"version": "7.28.6",
|
"version": "7.28.6",
|
||||||
@@ -10153,6 +10466,60 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="
|
||||||
|
},
|
||||||
|
"@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
|
||||||
|
},
|
||||||
|
"@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="
|
||||||
|
},
|
||||||
|
"@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"requires": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="
|
||||||
|
},
|
||||||
|
"@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"requires": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@types/d3-shape": {
|
||||||
|
"version": "3.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||||
|
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||||
|
"requires": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="
|
||||||
|
},
|
||||||
|
"@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
|
||||||
|
},
|
||||||
"@types/graceful-fs": {
|
"@types/graceful-fs": {
|
||||||
"version": "4.1.9",
|
"version": "4.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
|
||||||
@@ -10711,6 +11078,11 @@
|
|||||||
"wrap-ansi": "^7.0.0"
|
"wrap-ansi": "^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"clsx": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="
|
||||||
|
},
|
||||||
"co": {
|
"co": {
|
||||||
"version": "4.6.0",
|
"version": "4.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
||||||
@@ -10820,9 +11192,84 @@
|
|||||||
"csstype": {
|
"csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
|
||||||
"dev": true,
|
},
|
||||||
"peer": true
|
"d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"requires": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="
|
||||||
|
},
|
||||||
|
"d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="
|
||||||
|
},
|
||||||
|
"d3-format": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="
|
||||||
|
},
|
||||||
|
"d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"requires": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="
|
||||||
|
},
|
||||||
|
"d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"requires": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"requires": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"requires": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"requires": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="
|
||||||
},
|
},
|
||||||
"date-fns": {
|
"date-fns": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
@@ -10844,6 +11291,11 @@
|
|||||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"decimal.js-light": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="
|
||||||
|
},
|
||||||
"dedent": {
|
"dedent": {
|
||||||
"version": "1.7.2",
|
"version": "1.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
||||||
@@ -10946,6 +11398,15 @@
|
|||||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"dom-helpers": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||||
|
"requires": {
|
||||||
|
"@babel/runtime": "^7.8.7",
|
||||||
|
"csstype": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"domexception": {
|
"domexception": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz",
|
||||||
@@ -11116,6 +11577,11 @@
|
|||||||
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
|
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"eventemitter3": {
|
||||||
|
"version": "4.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||||
|
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
|
||||||
|
},
|
||||||
"exit": {
|
"exit": {
|
||||||
"version": "0.1.2",
|
"version": "0.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz",
|
||||||
@@ -11135,6 +11601,11 @@
|
|||||||
"jest-util": "^29.7.0"
|
"jest-util": "^29.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"fast-equals": {
|
||||||
|
"version": "5.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
|
||||||
|
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw=="
|
||||||
|
},
|
||||||
"fast-glob": {
|
"fast-glob": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||||
@@ -11457,6 +11928,11 @@
|
|||||||
"side-channel": "^1.1.0"
|
"side-channel": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="
|
||||||
|
},
|
||||||
"is-arguments": {
|
"is-arguments": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
|
||||||
@@ -12669,6 +13145,11 @@
|
|||||||
"p-locate": "^4.1.0"
|
"p-locate": "^4.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"lodash": {
|
||||||
|
"version": "4.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||||
|
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="
|
||||||
|
},
|
||||||
"lodash.debounce": {
|
"lodash.debounce": {
|
||||||
"version": "4.0.8",
|
"version": "4.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
|
||||||
@@ -12835,8 +13316,7 @@
|
|||||||
"object-assign": {
|
"object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"object-hash": {
|
"object-hash": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
@@ -13101,6 +13581,23 @@
|
|||||||
"sisteransi": "^1.0.5"
|
"sisteransi": "^1.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"prop-types": {
|
||||||
|
"version": "15.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
|
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||||
|
"requires": {
|
||||||
|
"loose-envify": "^1.4.0",
|
||||||
|
"object-assign": "^4.1.1",
|
||||||
|
"react-is": "^16.13.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react-is": {
|
||||||
|
"version": "16.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"proxy-from-env": {
|
"proxy-from-env": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||||
@@ -13185,6 +13682,27 @@
|
|||||||
"react-router": "6.30.3"
|
"react-router": "6.30.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"react-smooth": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
|
||||||
|
"requires": {
|
||||||
|
"fast-equals": "^5.0.1",
|
||||||
|
"prop-types": "^15.8.1",
|
||||||
|
"react-transition-group": "^4.4.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"react-transition-group": {
|
||||||
|
"version": "4.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||||
|
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
|
||||||
|
"requires": {
|
||||||
|
"@babel/runtime": "^7.5.5",
|
||||||
|
"dom-helpers": "^5.0.1",
|
||||||
|
"loose-envify": "^1.4.0",
|
||||||
|
"prop-types": "^15.6.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"read-cache": {
|
"read-cache": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||||
@@ -13203,6 +13721,36 @@
|
|||||||
"picomatch": "^2.2.1"
|
"picomatch": "^2.2.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"recharts": {
|
||||||
|
"version": "2.15.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
|
||||||
|
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
|
||||||
|
"requires": {
|
||||||
|
"clsx": "^2.0.0",
|
||||||
|
"eventemitter3": "^4.0.1",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"react-is": "^18.3.1",
|
||||||
|
"react-smooth": "^4.0.4",
|
||||||
|
"recharts-scale": "^0.4.4",
|
||||||
|
"tiny-invariant": "^1.3.1",
|
||||||
|
"victory-vendor": "^36.6.8"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react-is": {
|
||||||
|
"version": "18.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||||
|
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"recharts-scale": {
|
||||||
|
"version": "0.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
|
||||||
|
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
|
||||||
|
"requires": {
|
||||||
|
"decimal.js-light": "^2.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"redent": {
|
"redent": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||||
@@ -13674,6 +14222,11 @@
|
|||||||
"thenify": ">= 3.1.0 < 4"
|
"thenify": ">= 3.1.0 < 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"tiny-invariant": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
|
||||||
|
},
|
||||||
"tinyglobby": {
|
"tinyglobby": {
|
||||||
"version": "0.2.16",
|
"version": "0.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||||
@@ -13815,6 +14368,27 @@
|
|||||||
"convert-source-map": "^2.0.0"
|
"convert-source-map": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"victory-vendor": {
|
||||||
|
"version": "36.9.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
|
||||||
|
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
|
||||||
|
"requires": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"vite": {
|
"vite": {
|
||||||
"version": "4.5.14",
|
"version": "4.5.14",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.14.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.14.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.7.2",
|
"axios": "^1.7.2",
|
||||||
|
"recharts": "^2.12.7",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
|||||||
+21
-3
@@ -1,8 +1,10 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Routes, Route, Link, useLocation } from 'react-router-dom';
|
import { Routes, Route, Link, useLocation } from 'react-router-dom';
|
||||||
|
import { systemApi } from './api/client';
|
||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
import ExperimentDetail from './pages/ExperimentDetail';
|
import ExperimentDetail from './pages/ExperimentDetail';
|
||||||
import AnimalDetail from './pages/AnimalDetail';
|
import AnimalDetail from './pages/AnimalDetail';
|
||||||
|
import DailyStatusDetail from './pages/DailyStatusDetail';
|
||||||
|
|
||||||
function Navbar() {
|
function Navbar() {
|
||||||
return (
|
return (
|
||||||
@@ -33,18 +35,34 @@ function NotFound() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Footer() {
|
||||||
|
const [dbTimezone, setDbTimezone] = useState(null);
|
||||||
|
useEffect(() => {
|
||||||
|
systemApi.info().then((d) => setDbTimezone(d.dbTimezone)).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<footer className="border-t border-gray-200 mt-12 py-3">
|
||||||
|
<div className="max-w-5xl mx-auto px-4 text-xs text-gray-400 text-right">
|
||||||
|
Database timezone: <span className="font-mono">{dbTimezone ?? '…'}</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50 flex flex-col">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<main>
|
<main className="flex-1">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/experiments/:id" element={<ExperimentDetail />} />
|
<Route path="/experiments/:id" element={<ExperimentDetail />} />
|
||||||
<Route path="/animals/:id" element={<AnimalDetail />} />
|
<Route path="/animals/:id" element={<AnimalDetail />} />
|
||||||
|
<Route path="/daily-statuses/:id" element={<DailyStatusDetail />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ export const experimentsApi = {
|
|||||||
delete: (id) => api.delete(`/experiments/${id}`),
|
delete: (id) => api.delete(`/experiments/${id}`),
|
||||||
getTemplate: (id) => api.get(`/experiments/${id}/template`).then((r) => r.data),
|
getTemplate: (id) => api.get(`/experiments/${id}/template`).then((r) => r.data),
|
||||||
updateTemplate: (id, template) => api.put(`/experiments/${id}/template`, template).then((r) => r.data),
|
updateTemplate: (id, template) => api.put(`/experiments/${id}/template`, template).then((r) => r.data),
|
||||||
|
getSubjectTemplate: (id) => api.get(`/experiments/${id}/subject-template`).then((r) => r.data),
|
||||||
|
updateSubjectTemplate: (id, template) => api.put(`/experiments/${id}/subject-template`, template).then((r) => r.data),
|
||||||
|
getDailyStatuses: (id) => api.get(`/experiments/${id}/daily-statuses`).then((r) => r.data),
|
||||||
|
getCalendar: (id, field) => api.get(`/experiments/${id}/calendar`, { params: { field } }).then((r) => r.data),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Animals ───────────────────────────────────────────────────────────────────
|
// ── Animals ───────────────────────────────────────────────────────────────────
|
||||||
@@ -51,9 +55,23 @@ export const dailyStatusesApi = {
|
|||||||
create: (data) => api.post('/daily-statuses', data).then((r) => r.data),
|
create: (data) => api.post('/daily-statuses', data).then((r) => r.data),
|
||||||
update: (id, data) => api.put(`/daily-statuses/${id}`, data).then((r) => r.data),
|
update: (id, data) => api.put(`/daily-statuses/${id}`, data).then((r) => r.data),
|
||||||
delete: (id) => api.delete(`/daily-statuses/${id}`),
|
delete: (id) => api.delete(`/daily-statuses/${id}`),
|
||||||
|
updateAnalysisSummary: (id, data) => api.put(`/daily-statuses/${id}/analysis-summary`, data).then((r) => r.data),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Audit Logs ────────────────────────────────────────────────────────────────
|
// ── Audit Logs ────────────────────────────────────────────────────────────────
|
||||||
export const auditLogsApi = {
|
export const auditLogsApi = {
|
||||||
list: (params) => api.get('/audit-logs', { params }).then((r) => r.data),
|
list: (params) => api.get('/audit-logs', { params }).then((r) => r.data),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── System ────────────────────────────────────────────────────────────────────
|
||||||
|
export const systemApi = {
|
||||||
|
info: () => api.get('/info').then((r) => r.data),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Analyses ──────────────────────────────────────────────────────────────────
|
||||||
|
export const analysesApi = {
|
||||||
|
listForStatus: (statusId) => api.get(`/daily-statuses/${statusId}/analyses`).then((r) => r.data),
|
||||||
|
create: (statusId, data) => api.post(`/daily-statuses/${statusId}/analyses`, data).then((r) => r.data),
|
||||||
|
get: (id) => api.get(`/analyses/${id}`).then((r) => r.data),
|
||||||
|
delete: (id) => api.delete(`/analyses/${id}`),
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,413 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import {
|
||||||
|
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||||
|
Legend, ResponsiveContainer,
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
// ── Color palettes ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CAT_COLORS = { Success: '#22c55e', Failure: '#ef4444', Other: '#9ca3af' };
|
||||||
|
const EXTRA_CAT = ['#f59e0b', '#a855f7', '#ec4899', '#14b8a6', '#f97316'];
|
||||||
|
const GROUP_COLORS = ['#6366f1', '#f59e0b', '#22c55e', '#ef4444', '#a855f7', '#14b8a6', '#ec4899', '#f97316', '#84cc16', '#06b6d4'];
|
||||||
|
|
||||||
|
function catColor(cat, idx = 0) {
|
||||||
|
return CAT_COLORS[cat] ?? EXTRA_CAT[idx % EXTRA_CAT.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared tooltip ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ChartTooltip({ active, payload, label, labelKey = 'dateStr', unit = '' }) {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
const header = payload[0]?.payload?.[labelKey] ?? `Day ${label}`;
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-gray-200 rounded shadow-sm px-3 py-2 text-xs space-y-0.5">
|
||||||
|
<p className="font-semibold text-gray-700 mb-1">{header}</p>
|
||||||
|
{payload.map((p) => (
|
||||||
|
<p key={p.dataKey} style={{ color: p.stroke ?? p.color }}>
|
||||||
|
{p.name}: {p.value != null ? `${p.value}${unit}` : '—'}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DAYS_REACH_RE = /#?\s*days?\s*reach/i;
|
||||||
|
|
||||||
|
/** Pick the default xField from a list of active daily-template fields. */
|
||||||
|
function defaultXField(fields) {
|
||||||
|
return fields.find((f) => DAYS_REACH_RE.test(f.label))?.fieldId ?? '__days__';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a template field's value from a status row (numeric, or null). */
|
||||||
|
function numericFieldVal(status, field) {
|
||||||
|
if (!field) return null;
|
||||||
|
const raw = field.builtin ? status[field.key] : status.custom_fields?.[field.fieldId];
|
||||||
|
const n = parseFloat(raw);
|
||||||
|
return isNaN(n) ? null : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SELECT_CLS = 'border border-gray-200 rounded px-1.5 py-0.5 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400';
|
||||||
|
|
||||||
|
const STEP_OPTIONS = [1, 2, 5, 10];
|
||||||
|
|
||||||
|
/** Build explicit integer ticks for a numeric x-axis so every step value is shown. */
|
||||||
|
function buildTicks(data, step) {
|
||||||
|
const xs = data.map((d) => d.x).filter((x) => x != null && isFinite(x));
|
||||||
|
if (!xs.length) return undefined;
|
||||||
|
const lo = Math.floor(Math.min(...xs));
|
||||||
|
const hi = Math.ceil(Math.max(...xs));
|
||||||
|
const ticks = [];
|
||||||
|
for (let t = Math.ceil(lo / step) * step; t <= hi; t += step) ticks.push(t);
|
||||||
|
if (!ticks.length || ticks[0] > lo) ticks.unshift(lo);
|
||||||
|
if (ticks[ticks.length - 1] < hi) ticks.push(hi);
|
||||||
|
return ticks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TickStepControl({ value, onChange }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
|
<span className="text-gray-400">Tick every</span>
|
||||||
|
<div className="flex rounded border border-gray-200 overflow-hidden">
|
||||||
|
{STEP_OPTIONS.map((s) => (
|
||||||
|
<button key={s} type="button" onClick={() => onChange(s)}
|
||||||
|
className={`px-2 py-0.5 transition-colors border-l border-gray-200 first:border-l-0
|
||||||
|
${value === s ? 'bg-gray-700 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Subject charts ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function SubjectAnalysisCharts({ statuses, dailyTemplate }) {
|
||||||
|
const xAxisFields = (dailyTemplate ?? []).filter((f) => f.active);
|
||||||
|
const [xField, setXField] = useState(() => defaultXField(xAxisFields));
|
||||||
|
const [tickStep, setTickStep] = useState(1);
|
||||||
|
|
||||||
|
const { data, cats, xLabel } = useMemo(() => {
|
||||||
|
const xAxisField = xAxisFields.find((f) => f.fieldId === xField);
|
||||||
|
const xLabel = xAxisField ? xAxisField.label : '# Days Reach';
|
||||||
|
|
||||||
|
const withMetrics = statuses
|
||||||
|
.filter((s) => s.analysis_summary?.total != null)
|
||||||
|
.sort((a, b) => String(a.date).localeCompare(String(b.date)));
|
||||||
|
|
||||||
|
const rows = withMetrics.map((s, i) => {
|
||||||
|
const xVal = xField === '__days__' || !xAxisField
|
||||||
|
? i + 1
|
||||||
|
: numericFieldVal(s, xAxisField);
|
||||||
|
const dateStr = format(new Date(String(s.date).slice(0, 10) + 'T12:00:00'), 'MMM d');
|
||||||
|
const { counts = {}, total, success_rate } = s.analysis_summary;
|
||||||
|
return { _x: xVal, dateStr, total, rate: success_rate != null ? +(success_rate * 100).toFixed(1) : null, ...counts };
|
||||||
|
}).filter((r) => r._x != null).sort((a, b) => a._x - b._x);
|
||||||
|
|
||||||
|
const data = rows.map((r) => ({ ...r, x: r._x }));
|
||||||
|
|
||||||
|
const catSet = new Set();
|
||||||
|
for (const d of data) {
|
||||||
|
for (const k of Object.keys(d)) {
|
||||||
|
if (!['x', '_x', 'dateStr', 'total', 'rate'].includes(k)) catSet.add(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { data, cats: [...catSet], xLabel };
|
||||||
|
}, [statuses, xField, dailyTemplate]);
|
||||||
|
|
||||||
|
const ticks = useMemo(() => buildTicks(data, tickStep), [data, tickStep]);
|
||||||
|
|
||||||
|
if (data.length === 0) return null;
|
||||||
|
|
||||||
|
const xAxisProps = {
|
||||||
|
dataKey: 'x', type: 'number', tick: { fontSize: 10 },
|
||||||
|
ticks,
|
||||||
|
domain: ticks?.length ? [ticks[0], ticks[ticks.length - 1]] : ['auto', 'auto'],
|
||||||
|
label: { value: xLabel, position: 'insideBottomRight', offset: -4, fontSize: 10 },
|
||||||
|
allowDecimals: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-xl p-5 mb-6 space-y-5">
|
||||||
|
<div className="flex items-center flex-wrap gap-3">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">Session metrics over time</h2>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
|
<span className="text-gray-400">X axis:</span>
|
||||||
|
<select value={xField} onChange={(e) => setXField(e.target.value)} className={SELECT_CLS}>
|
||||||
|
<option value="__days__"># Days Reach (ordinal)</option>
|
||||||
|
{xAxisFields.map((f) => (
|
||||||
|
<option key={f.fieldId} value={f.fieldId}>{f.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<TickStepControl value={tickStep} onChange={setTickStep} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart 1 — counts */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 mb-2">Attempts per session</p>
|
||||||
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
|
<LineChart data={data} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#f3f4f6" />
|
||||||
|
<XAxis {...xAxisProps} />
|
||||||
|
<YAxis tick={{ fontSize: 10 }} width={30} allowDecimals={false} />
|
||||||
|
<Tooltip content={<ChartTooltip labelKey="dateStr" />} />
|
||||||
|
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||||
|
{cats.map((cat, i) => (
|
||||||
|
<Line key={cat} type="monotone" dataKey={cat} stroke={catColor(cat, i)} strokeWidth={2} dot={{ r: 3 }} activeDot={{ r: 4 }} connectNulls isAnimationActive={false} />
|
||||||
|
))}
|
||||||
|
<Line type="monotone" dataKey="total" name="Total" stroke="#3b82f6" strokeWidth={1.5} strokeDasharray="5 3" dot={{ r: 3 }} connectNulls isAnimationActive={false} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart 2 — success rate */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 mb-2">Success rate (%)</p>
|
||||||
|
<ResponsiveContainer width="100%" height={160}>
|
||||||
|
<LineChart data={data} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#f3f4f6" />
|
||||||
|
<XAxis {...xAxisProps} />
|
||||||
|
<YAxis tick={{ fontSize: 10 }} width={36} domain={[0, 100]} tickFormatter={(v) => `${v}%`} />
|
||||||
|
<Tooltip content={<ChartTooltip unit="%" labelKey="dateStr" />} />
|
||||||
|
<Line type="monotone" dataKey="rate" name="Success rate" stroke="#6366f1" strokeWidth={2} dot={{ r: 3 }} connectNulls isAnimationActive={false} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Experiment charts ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const METRIC_OPTIONS = [
|
||||||
|
{ value: 'total', label: 'Total attempts' },
|
||||||
|
{ value: 'Success', label: 'Success count' },
|
||||||
|
{ value: 'Failure', label: 'Failure count' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectTemplate, dailyTemplate }) {
|
||||||
|
const groupableFields = (subjectTemplate ?? []).filter((f) => f.active);
|
||||||
|
const xAxisFields = (dailyTemplate ?? []).filter((f) => f.active);
|
||||||
|
|
||||||
|
const defaultGroup = (groupableFields.find((f) => /^group$/i.test(f.label)) ?? groupableFields[0])?.fieldId ?? '';
|
||||||
|
const [groupBy, setGroupBy] = useState(defaultGroup);
|
||||||
|
const [metric, setMetric] = useState('total');
|
||||||
|
const [xField, setXField] = useState(() => defaultXField(xAxisFields));
|
||||||
|
const [tickStep, setTickStep] = useState(1);
|
||||||
|
|
||||||
|
const { countData, rateData, lines, xLabel } = useMemo(() => {
|
||||||
|
// animalId → group label
|
||||||
|
const animalGroup = {};
|
||||||
|
for (const animal of animals) {
|
||||||
|
const raw = groupBy ? animal.subject_info?.[groupBy] : null;
|
||||||
|
animalGroup[animal.id] = raw != null && raw !== '' ? String(raw) : '—';
|
||||||
|
}
|
||||||
|
const groups = [...new Set(Object.values(animalGroup))].sort();
|
||||||
|
|
||||||
|
// animalId → sorted statuses that have saved metrics (full status rows)
|
||||||
|
const statusesByAnimal = {};
|
||||||
|
for (const s of experimentStatuses) {
|
||||||
|
if (!statusesByAnimal[s.animal_id]) statusesByAnimal[s.animal_id] = [];
|
||||||
|
statusesByAnimal[s.animal_id].push(s);
|
||||||
|
}
|
||||||
|
const animalRows = {}; // full status rows, sorted by date
|
||||||
|
for (const animal of animals) {
|
||||||
|
animalRows[animal.id] = (statusesByAnimal[animal.id] ?? [])
|
||||||
|
.filter((s) => s.analysis_summary?.total != null)
|
||||||
|
.sort((a, b) => String(a.date).localeCompare(String(b.date)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = animals
|
||||||
|
.filter((a) => animalRows[a.id].length > 0)
|
||||||
|
.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
name: a.animal_name,
|
||||||
|
group: animalGroup[a.id],
|
||||||
|
color: GROUP_COLORS[groups.indexOf(animalGroup[a.id]) % GROUP_COLORS.length],
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (lines.length === 0) return { countData: [], rateData: [], lines: [], xLabel: '' };
|
||||||
|
|
||||||
|
const xAxisField = xAxisFields.find((f) => f.fieldId === xField);
|
||||||
|
const xLabel = xAxisField ? xAxisField.label : '# Days Reach';
|
||||||
|
|
||||||
|
function getX(row, dayIndex) {
|
||||||
|
if (xField === '__days__') return dayIndex + 1;
|
||||||
|
return numericFieldVal(row, xAxisField);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCountVal(summary) {
|
||||||
|
return metric === 'total'
|
||||||
|
? summary.total
|
||||||
|
: (summary.counts?.[metric] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (xField === '__days__') {
|
||||||
|
// Ordinal alignment: position by day index
|
||||||
|
const maxDay = Math.max(...lines.map((l) => animalRows[l.id].length));
|
||||||
|
const countData = Array.from({ length: maxDay }, (_, i) => ({ x: i + 1 }));
|
||||||
|
const rateData = Array.from({ length: maxDay }, (_, i) => ({ x: i + 1 }));
|
||||||
|
for (const line of lines) {
|
||||||
|
animalRows[line.id].forEach((row, i) => {
|
||||||
|
const s = row.analysis_summary;
|
||||||
|
countData[i][line.id] = getCountVal(s);
|
||||||
|
rateData[i][line.id] = s.success_rate != null ? +(s.success_rate * 100).toFixed(1) : null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { countData, rateData, lines, xLabel };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Field-based x axis: merge all unique x values across animals
|
||||||
|
const allPts = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
animalRows[line.id].forEach((row, i) => {
|
||||||
|
const x = getX(row, i);
|
||||||
|
if (x !== null) allPts.push({ x, animalId: line.id, summary: row.analysis_summary });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const sortedX = [...new Set(allPts.map((p) => p.x))].sort((a, b) => a - b);
|
||||||
|
if (sortedX.length === 0) return { countData: [], rateData: [], lines, xLabel };
|
||||||
|
|
||||||
|
const countData = sortedX.map((x) => {
|
||||||
|
const pt = { x };
|
||||||
|
for (const p of allPts.filter((p) => p.x === x)) {
|
||||||
|
pt[p.animalId] = getCountVal(p.summary);
|
||||||
|
}
|
||||||
|
return pt;
|
||||||
|
});
|
||||||
|
const rateData = sortedX.map((x) => {
|
||||||
|
const pt = { x };
|
||||||
|
for (const p of allPts.filter((p) => p.x === x)) {
|
||||||
|
const s = p.summary;
|
||||||
|
pt[p.animalId] = s.success_rate != null ? +(s.success_rate * 100).toFixed(1) : null;
|
||||||
|
}
|
||||||
|
return pt;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { countData, rateData, lines, xLabel };
|
||||||
|
}, [animals, experimentStatuses, subjectTemplate, dailyTemplate, groupBy, metric, xField]);
|
||||||
|
|
||||||
|
const hasData = lines.length > 0;
|
||||||
|
const countTicks = useMemo(() => buildTicks(countData, tickStep), [countData, tickStep]);
|
||||||
|
const rateTicks = useMemo(() => buildTicks(rateData, tickStep), [rateData, tickStep]);
|
||||||
|
|
||||||
|
function xAxisProps(ticks) {
|
||||||
|
return {
|
||||||
|
dataKey: 'x', type: 'number', tick: { fontSize: 10 }, allowDecimals: false,
|
||||||
|
ticks,
|
||||||
|
domain: ticks?.length ? [ticks[0], ticks[ticks.length - 1]] : ['auto', 'auto'],
|
||||||
|
label: { value: xLabel, position: 'insideBottomRight', offset: -4, fontSize: 10 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExpTooltip({ active, payload, label, unit = '' }) {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-gray-200 rounded shadow-sm px-3 py-2 text-xs space-y-0.5 max-h-48 overflow-y-auto">
|
||||||
|
<p className="font-semibold text-gray-700 mb-1">{xLabel}: {label}</p>
|
||||||
|
{payload.map((p) => {
|
||||||
|
const line = lines.find((l) => l.id === p.dataKey);
|
||||||
|
return p.value != null ? (
|
||||||
|
<p key={p.dataKey} style={{ color: p.stroke }}>
|
||||||
|
{line?.name ?? p.dataKey}{line?.group && line.group !== '—' ? ` (${line.group})` : ''}: {p.value}{unit}
|
||||||
|
</p>
|
||||||
|
) : null;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-xl p-5 mb-6 space-y-5">
|
||||||
|
<div className="flex items-center flex-wrap gap-3">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">Cross-subject metrics</h2>
|
||||||
|
|
||||||
|
{/* X-axis field selector */}
|
||||||
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
|
<span className="text-gray-400">X axis:</span>
|
||||||
|
<select value={xField} onChange={(e) => setXField(e.target.value)} className={SELECT_CLS}>
|
||||||
|
<option value="__days__"># Days Reach (ordinal)</option>
|
||||||
|
{xAxisFields.map((f) => (
|
||||||
|
<option key={f.fieldId} value={f.fieldId}>{f.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Color-by subject info field */}
|
||||||
|
{groupableFields.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
|
<span className="text-gray-400">Color by:</span>
|
||||||
|
<select value={groupBy} onChange={(e) => setGroupBy(e.target.value)} className={SELECT_CLS}>
|
||||||
|
<option value="">— none —</option>
|
||||||
|
{groupableFields.map((f) => (
|
||||||
|
<option key={f.fieldId} value={f.fieldId}>{f.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<TickStepControl value={tickStep} onChange={setTickStep} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!hasData && (
|
||||||
|
<p className="text-sm text-gray-400 italic">No sessions with saved metrics yet. Upload a CSV on a daily status page and click "Save metrics to daily status".</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasData && (
|
||||||
|
<>
|
||||||
|
{/* Chart 1 — counts */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<p className="text-xs text-gray-500">Count per session — each line is one subject</p>
|
||||||
|
<div className="flex rounded border border-gray-200 overflow-hidden text-[10px]">
|
||||||
|
{METRIC_OPTIONS.map((opt) => (
|
||||||
|
<button key={opt.value} type="button" onClick={() => setMetric(opt.value)}
|
||||||
|
className={`px-2 py-0.5 transition-colors border-l border-gray-200 first:border-l-0
|
||||||
|
${metric === opt.value ? 'bg-gray-700 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
|
<LineChart data={countData} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#f3f4f6" />
|
||||||
|
<XAxis {...xAxisProps(countTicks)} />
|
||||||
|
<YAxis tick={{ fontSize: 10 }} width={30} allowDecimals={false} />
|
||||||
|
<Tooltip content={<ExpTooltip />} />
|
||||||
|
<Legend wrapperStyle={{ fontSize: 11 }}
|
||||||
|
formatter={(value) => lines.find((l) => l.id === value)?.name ?? value} />
|
||||||
|
{lines.map((l) => (
|
||||||
|
<Line key={l.id} type="monotone" dataKey={l.id} name={l.id}
|
||||||
|
stroke={l.color} strokeWidth={2} dot={{ r: 3 }} activeDot={{ r: 5 }} connectNulls isAnimationActive={false} />
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart 2 — success rate */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 mb-2">Success rate (%) — each line is one subject</p>
|
||||||
|
<ResponsiveContainer width="100%" height={160}>
|
||||||
|
<LineChart data={rateData} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#f3f4f6" />
|
||||||
|
<XAxis {...xAxisProps(rateTicks)} />
|
||||||
|
<YAxis tick={{ fontSize: 10 }} width={36} domain={[0, 100]} tickFormatter={(v) => `${v}%`} />
|
||||||
|
<Tooltip content={<ExpTooltip unit="%" />} />
|
||||||
|
<Legend wrapperStyle={{ fontSize: 11 }}
|
||||||
|
formatter={(value) => lines.find((l) => l.id === value)?.name ?? value} />
|
||||||
|
{lines.map((l) => (
|
||||||
|
<Line key={l.id} type="monotone" dataKey={l.id} name={l.id}
|
||||||
|
stroke={l.color} strokeWidth={2} dot={{ r: 3 }} activeDot={{ r: 5 }} connectNulls isAnimationActive={false} />
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ const ACTION_BADGE = {
|
|||||||
DELETE: 'bg-red-100 text-red-800',
|
DELETE: 'bg-red-100 text-red-800',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AuditLogSection({ tableName, recordId }) {
|
export default function AuditLogSection({ tableName, recordId, limit = 50 }) {
|
||||||
const [logs, setLogs] = useState([]);
|
const [logs, setLogs] = useState([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -46,14 +46,14 @@ export default function AuditLogSection({ tableName, recordId }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
auditLogsApi
|
auditLogsApi
|
||||||
.list({ tableName, recordId, limit: 50 })
|
.list({ tableName, recordId, limit })
|
||||||
.then(({ logs, total }) => {
|
.then(({ logs, total }) => {
|
||||||
setLogs(logs);
|
setLogs(logs);
|
||||||
setTotal(total);
|
setTotal(total);
|
||||||
})
|
})
|
||||||
.catch((e) => setError(e.message))
|
.catch((e) => setError(e.message))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [tableName, recordId]);
|
}, [tableName, recordId, limit]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-label="Modification History" className="mt-8">
|
<section aria-label="Modification History" className="mt-8">
|
||||||
@@ -76,7 +76,7 @@ export default function AuditLogSection({ tableName, recordId }) {
|
|||||||
|
|
||||||
{logs.length > 0 && (
|
{logs.length > 0 && (
|
||||||
<div className="border border-gray-200 rounded-lg overflow-hidden divide-y divide-gray-100">
|
<div className="border border-gray-200 rounded-lg overflow-hidden divide-y divide-gray-100">
|
||||||
{logs.map((log) => (
|
{logs.slice(0, limit).map((log) => (
|
||||||
<div key={log.id} className="bg-white">
|
<div key={log.id} className="bg-white">
|
||||||
<button
|
<button
|
||||||
className="w-full text-left px-4 py-3 flex items-center gap-3 hover:bg-gray-50 transition-colors"
|
className="w-full text-left px-4 py-3 flex items-center gap-3 hover:bg-gray-50 transition-colors"
|
||||||
@@ -108,6 +108,12 @@ export default function AuditLogSection({ tableName, recordId }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{total > limit && (
|
||||||
|
<p className="mt-2 text-xs text-gray-400">
|
||||||
|
Showing {limit} of {total} entries — open the daily status page to see the full history.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,602 @@
|
|||||||
|
import React, { useState, useRef, useCallback } from 'react';
|
||||||
|
import { parseCSVHeaders, parseCSV, getUniqueNoteValues, runAnalysis, checkFrameConsecutive } from '../lib/csvAnalysis';
|
||||||
|
import { analysesApi, dailyStatusesApi } from '../api/client';
|
||||||
|
import Button from './ui/Button';
|
||||||
|
import RunSequenceView from './RunSequenceView';
|
||||||
|
|
||||||
|
// ── Constants ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DEFAULT_CATEGORIES = ['Success', 'Failure', 'Other'];
|
||||||
|
|
||||||
|
const CATEGORY_COLORS = {
|
||||||
|
Success: { bg: 'bg-green-100', border: 'border-green-300', text: 'text-green-800', dot: 'bg-green-500' },
|
||||||
|
Failure: { bg: 'bg-red-100', border: 'border-red-300', text: 'text-red-800', dot: 'bg-red-500' },
|
||||||
|
Other: { bg: 'bg-gray-100', border: 'border-gray-300', text: 'text-gray-700', dot: 'bg-gray-400' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const EXTRA_COLORS = [
|
||||||
|
{ bg: 'bg-indigo-100', border: 'border-indigo-300', text: 'text-indigo-800', dot: 'bg-indigo-500' },
|
||||||
|
{ bg: 'bg-yellow-100', border: 'border-yellow-300', text: 'text-yellow-800', dot: 'bg-yellow-500' },
|
||||||
|
{ bg: 'bg-purple-100', border: 'border-purple-300', text: 'text-purple-800', dot: 'bg-purple-500' },
|
||||||
|
{ bg: 'bg-pink-100', border: 'border-pink-300', text: 'text-pink-800', dot: 'bg-pink-500' },
|
||||||
|
{ bg: 'bg-teal-100', border: 'border-teal-300', text: 'text-teal-800', dot: 'bg-teal-500' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function getCategoryColors(cat, customCategories) {
|
||||||
|
if (CATEGORY_COLORS[cat]) return CATEGORY_COLORS[cat];
|
||||||
|
const idx = customCategories.indexOf(cat) % EXTRA_COLORS.length;
|
||||||
|
return EXTRA_COLORS[Math.max(0, idx)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sub-components ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function NoteChip({ value, category, colors, selected, onSelect, draggable, onDragStart }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
draggable={draggable}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onClick={onSelect}
|
||||||
|
title={category ? `Assigned to: ${category}` : 'Unassigned — click or drag to assign'}
|
||||||
|
className={`
|
||||||
|
inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-mono font-semibold
|
||||||
|
border cursor-pointer select-none transition-all
|
||||||
|
${colors ? `${colors.bg} ${colors.border} ${colors.text}` : 'bg-white border-gray-300 text-gray-700 hover:border-indigo-400'}
|
||||||
|
${selected ? 'ring-2 ring-indigo-500 ring-offset-1' : ''}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
{category && (
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${colors?.dot}`} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryBucket({ name, notes, colors, isOver, onDrop, onDragOver, onDragLeave, onRemoveNote, onClickAssign }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onDragOver={(e) => { e.preventDefault(); onDragOver(); }}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
onDrop={(e) => { e.preventDefault(); onDrop(); }}
|
||||||
|
onClick={onClickAssign}
|
||||||
|
className={`
|
||||||
|
rounded-lg border-2 border-dashed p-3 min-h-[80px] transition-all cursor-pointer
|
||||||
|
${isOver ? `${colors.border} ${colors.bg} scale-[1.02]` : 'border-gray-200 hover:border-gray-300'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<p className={`text-xs font-semibold uppercase tracking-wide mb-2 ${colors.text}`}>{name}</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5 min-h-[28px]">
|
||||||
|
{notes.length === 0 && (
|
||||||
|
<span className="text-xs text-gray-400 italic">Drop here</span>
|
||||||
|
)}
|
||||||
|
{notes.map((note) => (
|
||||||
|
<span key={note}
|
||||||
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-mono font-semibold ${colors.bg} ${colors.border} ${colors.text} border`}
|
||||||
|
>
|
||||||
|
{note}
|
||||||
|
<button type="button" onClick={(e) => { e.stopPropagation(); onRemoveNote(note); }}
|
||||||
|
className="opacity-50 hover:opacity-100 leading-none">✕</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main component ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function CsvAnalysis({ dailyStatusId, onSaved, onSummaryPushed }) {
|
||||||
|
// Phase: 'upload' | 'columns' | 'classify' | 'results'
|
||||||
|
const [phase, setPhase] = useState('upload');
|
||||||
|
|
||||||
|
// Upload phase
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
const [fileName, setFileName] = useState('');
|
||||||
|
const [rawText, setRawText] = useState('');
|
||||||
|
const [headers, setHeaders] = useState([]);
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
|
// Columns phase
|
||||||
|
const [timestampCol, setTimestampCol] = useState('');
|
||||||
|
const [noteCol, setNoteCol] = useState('');
|
||||||
|
const [frameCol, setFrameCol] = useState('');
|
||||||
|
const [frameCheck, setFrameCheck] = useState(null);
|
||||||
|
const [parsePending, setParsePending] = useState(false);
|
||||||
|
|
||||||
|
// Classify phase
|
||||||
|
const [rows, setRows] = useState([]);
|
||||||
|
const [uniqueNotes, setUniqueNotes] = useState([]);
|
||||||
|
const [categories, setCategories] = useState(DEFAULT_CATEGORIES);
|
||||||
|
const [customCategories, setCustomCategories] = useState([]);
|
||||||
|
const [classifications, setClassifications] = useState({});
|
||||||
|
const [selectedNote, setSelectedNote] = useState(null);
|
||||||
|
const [draggedNote, setDraggedNote] = useState(null);
|
||||||
|
const [overBucket, setOverBucket] = useState(null);
|
||||||
|
const [newCatName, setNewCatName] = useState('');
|
||||||
|
const [showNewCat, setShowNewCat] = useState(false);
|
||||||
|
|
||||||
|
// Results phase
|
||||||
|
const [results, setResults] = useState(null);
|
||||||
|
const [savedAnalysisId, setSavedAnalysisId] = useState(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState(null);
|
||||||
|
const [summaryPushing, setSummaryPushing] = useState(false);
|
||||||
|
const [summaryPushed, setSummaryPushed] = useState(false);
|
||||||
|
const [summaryError, setSummaryError] = useState(null);
|
||||||
|
|
||||||
|
// ── File reading ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function readFile(file) {
|
||||||
|
if (!file || !file.name.endsWith('.csv')) {
|
||||||
|
alert('Please upload a .csv file.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const text = e.target.result;
|
||||||
|
const hdrs = parseCSVHeaders(text);
|
||||||
|
setFileName(file.name);
|
||||||
|
setRawText(text);
|
||||||
|
setHeaders(hdrs);
|
||||||
|
// Default columns
|
||||||
|
const tsDefault = hdrs.find(h => /timestamp|time/i.test(h)) ?? hdrs[0] ?? '';
|
||||||
|
const noteDefault = hdrs.find(h => /note|label|event/i.test(h)) ?? hdrs[hdrs.length - 1] ?? '';
|
||||||
|
const frameDefault = hdrs.find(h => /frame/i.test(h)) ?? '';
|
||||||
|
setTimestampCol(tsDefault);
|
||||||
|
setNoteCol(noteDefault);
|
||||||
|
setFrameCol(frameDefault);
|
||||||
|
setFrameCheck(null);
|
||||||
|
setPhase('columns');
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragOver(false);
|
||||||
|
readFile(e.dataTransfer.files[0]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Column selection → parse data ────────────────────────────────────────────
|
||||||
|
|
||||||
|
function loadData() {
|
||||||
|
setParsePending(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
const { rows: parsed } = parseCSV(rawText);
|
||||||
|
const uniq = getUniqueNoteValues(parsed, noteCol);
|
||||||
|
setRows(parsed);
|
||||||
|
setUniqueNotes(uniq);
|
||||||
|
setClassifications({});
|
||||||
|
setSelectedNote(null);
|
||||||
|
if (frameCol) setFrameCheck(checkFrameConsecutive(parsed, frameCol));
|
||||||
|
setParsePending(false);
|
||||||
|
setPhase('classify');
|
||||||
|
}, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Classification helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function assign(note, category) {
|
||||||
|
setClassifications(prev => ({ ...prev, [note]: category }));
|
||||||
|
setSelectedNote(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unassign(note) {
|
||||||
|
setClassifications(prev => { const n = { ...prev }; delete n[note]; return n; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCategory() {
|
||||||
|
const name = newCatName.trim();
|
||||||
|
if (!name || categories.includes(name)) return;
|
||||||
|
setCategories(prev => [...prev, name]);
|
||||||
|
setCustomCategories(prev => [...prev, name]);
|
||||||
|
setNewCatName('');
|
||||||
|
setShowNewCat(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unassignedNotes = uniqueNotes.filter(n => !classifications[n]);
|
||||||
|
const allAssigned = uniqueNotes.length > 0 && unassignedNotes.length === 0;
|
||||||
|
|
||||||
|
// ── Run analysis ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function compute() {
|
||||||
|
const res = runAnalysis(rows, timestampCol, noteCol, classifications);
|
||||||
|
setResults(res);
|
||||||
|
setPhase('results');
|
||||||
|
|
||||||
|
if (!dailyStatusId) return;
|
||||||
|
setSaving(true);
|
||||||
|
setSaveError(null);
|
||||||
|
try {
|
||||||
|
const saved = await analysesApi.create(dailyStatusId, {
|
||||||
|
file_name: fileName || null,
|
||||||
|
timestamp_col: timestampCol,
|
||||||
|
note_col: noteCol,
|
||||||
|
classifications,
|
||||||
|
total_note_rows: res.totalNoteRows,
|
||||||
|
session_end_ts: res.sessionEndTs,
|
||||||
|
sequence: res.sequence,
|
||||||
|
category_counts: res.categoryCounts,
|
||||||
|
consecutive_stats: res.consecutiveStats,
|
||||||
|
runs: res.runs,
|
||||||
|
});
|
||||||
|
setSavedAnalysisId(saved.id);
|
||||||
|
onSaved?.(saved);
|
||||||
|
} catch (err) {
|
||||||
|
setSaveError(err.message ?? 'Failed to save analysis');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Push summary to daily status ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function pushSummary() {
|
||||||
|
if (!dailyStatusId || !results) return;
|
||||||
|
setSummaryPushing(true);
|
||||||
|
setSummaryPushed(false);
|
||||||
|
setSummaryError(null);
|
||||||
|
try {
|
||||||
|
const counts = {};
|
||||||
|
for (const [cat, data] of Object.entries(results.categoryCounts)) {
|
||||||
|
counts[cat] = data.total;
|
||||||
|
}
|
||||||
|
const total = Object.values(counts).reduce((a, b) => a + b, 0);
|
||||||
|
const successRate = counts['Success'] != null ? counts['Success'] / total : null;
|
||||||
|
await dailyStatusesApi.updateAnalysisSummary(dailyStatusId, {
|
||||||
|
counts,
|
||||||
|
total,
|
||||||
|
success_rate: successRate,
|
||||||
|
source_analysis_id: savedAnalysisId ?? null,
|
||||||
|
});
|
||||||
|
setSummaryPushed(true);
|
||||||
|
onSummaryPushed?.({ counts, total, success_rate: successRate });
|
||||||
|
} catch (err) {
|
||||||
|
setSummaryError(err.message ?? 'Failed to update');
|
||||||
|
} finally {
|
||||||
|
setSummaryPushing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reset ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setPhase('upload');
|
||||||
|
setRawText('');
|
||||||
|
setHeaders([]);
|
||||||
|
setRows([]);
|
||||||
|
setUniqueNotes([]);
|
||||||
|
setClassifications({});
|
||||||
|
setResults(null);
|
||||||
|
setSavedAnalysisId(null);
|
||||||
|
setSummaryPushed(false);
|
||||||
|
setSummaryError(null);
|
||||||
|
setCategories(DEFAULT_CATEGORIES);
|
||||||
|
setCustomCategories([]);
|
||||||
|
setFrameCheck(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-8 border-t border-gray-200 pt-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">CSV Analysis</h2>
|
||||||
|
{phase !== 'upload' && (
|
||||||
|
<button type="button" onClick={reset} className="text-xs text-gray-400 hover:text-gray-600">
|
||||||
|
✕ Start over
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── PHASE: upload ── */}
|
||||||
|
{phase === 'upload' && (
|
||||||
|
<div
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setIsDragOver(true); }}
|
||||||
|
onDragLeave={() => setIsDragOver(false)}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className={`
|
||||||
|
border-2 border-dashed rounded-xl p-10 text-center cursor-pointer transition-all
|
||||||
|
${isDragOver ? 'border-indigo-400 bg-indigo-50' : 'border-gray-200 hover:border-gray-300 bg-gray-50'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<input ref={fileInputRef} type="file" accept=".csv" className="hidden"
|
||||||
|
onChange={(e) => readFile(e.target.files[0])} />
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{isDragOver ? 'Drop CSV file to upload' : 'Drop a CSV file here, or click to browse'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">Columns will be read; data stays in your browser</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── PHASE: columns ── */}
|
||||||
|
{phase === 'columns' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
<span className="font-medium">{fileName}</span> loaded — {headers.length} columns detected. Select the columns to analyse.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-4 max-w-lg">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-500 mb-1">Timestamp column</label>
|
||||||
|
<select value={timestampCol} onChange={(e) => setTimestampCol(e.target.value)}
|
||||||
|
className="w-full text-sm border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-400">
|
||||||
|
{headers.map(h => <option key={h} value={h}>{h}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-500 mb-1">Note column</label>
|
||||||
|
<select value={noteCol} onChange={(e) => setNoteCol(e.target.value)}
|
||||||
|
className="w-full text-sm border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-400">
|
||||||
|
{headers.map(h => <option key={h} value={h}>{h}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||||
|
Frame number column <span className="font-normal text-gray-400">(gap check)</span>
|
||||||
|
</label>
|
||||||
|
<select value={frameCol} onChange={(e) => setFrameCol(e.target.value)}
|
||||||
|
className="w-full text-sm border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-400">
|
||||||
|
<option value="">— skip —</option>
|
||||||
|
{headers.map(h => <option key={h} value={h}>{h}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={loadData} disabled={parsePending}>
|
||||||
|
{parsePending ? 'Parsing…' : 'Load note values →'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── PHASE: classify ── */}
|
||||||
|
{phase === 'classify' && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
|
||||||
|
{/* Frame gap check results */}
|
||||||
|
{frameCheck && (
|
||||||
|
<div className={`rounded-lg border px-3 py-2.5 text-xs ${frameCheck.consecutive ? 'bg-green-50 border-green-200 text-green-800' : 'bg-amber-50 border-amber-200 text-amber-800'}`}>
|
||||||
|
<p className="font-semibold mb-1">
|
||||||
|
Frame check ({frameCol}): {frameCheck.consecutive ? `✓ all ${frameCheck.total} frames consecutive` : `⚠ issues found in ${frameCheck.total} frames`}
|
||||||
|
</p>
|
||||||
|
{frameCheck.gaps.length > 0 && (
|
||||||
|
<div className="mb-1">
|
||||||
|
<span className="font-medium">{frameCheck.gaps.length} gap{frameCheck.gaps.length !== 1 ? 's' : ''}:</span>
|
||||||
|
<ul className="mt-0.5 space-y-0.5 max-h-32 overflow-y-auto font-mono">
|
||||||
|
{frameCheck.gaps.map((g, i) => (
|
||||||
|
<li key={i}>row {g.rowIndex + 1}: expected {g.expected}, got {g.actual} (jump +{g.jump})</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{frameCheck.duplicates.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">{frameCheck.duplicates.length} duplicate{frameCheck.duplicates.length !== 1 ? 's' : ''}:</span>
|
||||||
|
<ul className="mt-0.5 space-y-0.5 font-mono max-h-20 overflow-y-auto">
|
||||||
|
{frameCheck.duplicates.map((d, i) => (
|
||||||
|
<li key={i}>row {d.rowIndex + 1}: frame {d.value}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Found <span className="font-semibold">{uniqueNotes.length}</span> unique note value{uniqueNotes.length !== 1 ? 's' : ''}.
|
||||||
|
{selectedNote && (
|
||||||
|
<span className="ml-2 text-indigo-600 font-medium">
|
||||||
|
Selected: <span className="font-mono">{selectedNote}</span> — click a category to assign
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Unassigned chips */}
|
||||||
|
{unassignedNotes.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1.5">Unassigned</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{unassignedNotes.map(note => (
|
||||||
|
<NoteChip key={note} value={note} category={null} colors={null}
|
||||||
|
selected={selectedNote === note}
|
||||||
|
onSelect={() => setSelectedNote(prev => prev === note ? null : note)}
|
||||||
|
draggable onDragStart={() => setDraggedNote(note)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Category buckets */}
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{categories.map(cat => {
|
||||||
|
const colors = getCategoryColors(cat, customCategories);
|
||||||
|
const notesInCat = uniqueNotes.filter(n => classifications[n] === cat);
|
||||||
|
return (
|
||||||
|
<CategoryBucket key={cat} name={cat} notes={notesInCat} colors={colors}
|
||||||
|
isOver={overBucket === cat}
|
||||||
|
onDragOver={() => setOverBucket(cat)}
|
||||||
|
onDragLeave={() => setOverBucket(null)}
|
||||||
|
onDrop={() => {
|
||||||
|
if (draggedNote) { assign(draggedNote, cat); setDraggedNote(null); setOverBucket(null); }
|
||||||
|
}}
|
||||||
|
onClickAssign={() => { if (selectedNote) assign(selectedNote, cat); }}
|
||||||
|
onRemoveNote={(note) => unassign(note)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add new category */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{showNewCat ? (
|
||||||
|
<>
|
||||||
|
<input autoFocus value={newCatName} onChange={(e) => setNewCatName(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') addCategory(); if (e.key === 'Escape') setShowNewCat(false); }}
|
||||||
|
placeholder="Category name…"
|
||||||
|
className="text-sm border border-gray-300 rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-indigo-400 w-40" />
|
||||||
|
<Button size="sm" type="button" onClick={addCategory}>Add</Button>
|
||||||
|
<button type="button" onClick={() => setShowNewCat(false)} className="text-xs text-gray-400 hover:text-gray-600">Cancel</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button type="button" onClick={() => setShowNewCat(true)}
|
||||||
|
className="text-xs text-indigo-500 hover:text-indigo-700 border border-dashed border-indigo-300 rounded-full px-3 py-0.5 hover:border-indigo-500 transition-colors">
|
||||||
|
+ New category
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Unclassified warning */}
|
||||||
|
{!allAssigned && uniqueNotes.length > 0 && (
|
||||||
|
<p className="text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded px-3 py-1.5">
|
||||||
|
{unassignedNotes.length} unassigned note value{unassignedNotes.length !== 1 ? 's' : ''} will be excluded from the analysis.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button onClick={compute} disabled={uniqueNotes.length === 0}>
|
||||||
|
Run analysis →
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── PHASE: results ── */}
|
||||||
|
{phase === 'results' && results && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Analysed <span className="font-semibold">{results.totalNoteRows}</span> note rows
|
||||||
|
· columns: <span className="font-mono text-xs">{timestampCol}</span> / <span className="font-mono text-xs">{noteCol}</span>
|
||||||
|
</p>
|
||||||
|
{dailyStatusId && (
|
||||||
|
<span className="text-xs">
|
||||||
|
{saving && <span className="text-gray-400">Saving…</span>}
|
||||||
|
{!saving && !saveError && results && <span className="text-green-600">✓ Saved</span>}
|
||||||
|
{saveError && <span className="text-red-500" title={saveError}>⚠ Save failed</span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category counts */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">Category Counts</h3>
|
||||||
|
<table className="w-full text-sm border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">Category</th>
|
||||||
|
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">Note breakdown</th>
|
||||||
|
<th className="text-right px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{Object.entries(results.categoryCounts)
|
||||||
|
.sort(([, a], [, b]) => b.total - a.total)
|
||||||
|
.map(([cat, data]) => {
|
||||||
|
const colors = getCategoryColors(cat, customCategories);
|
||||||
|
const breakdown = Object.entries(data.byNote)
|
||||||
|
.sort(([, a], [, b]) => b - a)
|
||||||
|
.map(([n, c]) => `${n}: ${c}`)
|
||||||
|
.join(' · ');
|
||||||
|
return (
|
||||||
|
<tr key={cat} className="border-b border-gray-100">
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-semibold ${colors.bg} ${colors.text}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${colors.dot}`} />
|
||||||
|
{cat}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-gray-500 font-mono text-xs">{breakdown}</td>
|
||||||
|
<td className="px-3 py-2 text-right font-semibold text-gray-800">{data.total}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{Object.keys(results.categoryCounts).length === 0 && (
|
||||||
|
<tr><td colSpan={3} className="px-3 py-4 text-center text-gray-400 italic text-sm">No classified notes</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Consecutive stats */}
|
||||||
|
{Object.keys(results.consecutiveStats).length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">Consecutive Runs</h3>
|
||||||
|
<p className="text-xs text-gray-400 mb-2">
|
||||||
|
How many times each category appeared in a row (run-length encoding of the category sequence, ordered by timestamp).
|
||||||
|
</p>
|
||||||
|
<table className="w-full text-sm border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">Category</th>
|
||||||
|
<th className="text-right px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">Total runs</th>
|
||||||
|
<th className="text-right px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">Max run</th>
|
||||||
|
<th className="text-right px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">Avg run</th>
|
||||||
|
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">Run lengths</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{Object.entries(results.consecutiveStats)
|
||||||
|
.sort(([, a], [, b]) => b.totalRuns - a.totalRuns)
|
||||||
|
.map(([cat, s]) => {
|
||||||
|
const colors = getCategoryColors(cat, customCategories);
|
||||||
|
const runSummary = s.runLengths
|
||||||
|
.reduce((acc, len) => {
|
||||||
|
acc[len] = (acc[len] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
const runDisplay = Object.entries(runSummary)
|
||||||
|
.sort(([a], [b]) => Number(a) - Number(b))
|
||||||
|
.map(([len, cnt]) => `${len}×${cnt}`)
|
||||||
|
.join(', ');
|
||||||
|
return (
|
||||||
|
<tr key={cat} className="border-b border-gray-100">
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-semibold ${colors.bg} ${colors.text}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${colors.dot}`} />
|
||||||
|
{cat}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right text-gray-700">{s.totalRuns}</td>
|
||||||
|
<td className="px-3 py-2 text-right text-gray-700">{s.maxRun}</td>
|
||||||
|
<td className="px-3 py-2 text-right text-gray-700">{s.avgRun.toFixed(2)}</td>
|
||||||
|
<td className="px-3 py-2 text-gray-500 font-mono text-xs">{runDisplay}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<RunSequenceView
|
||||||
|
runs={results.runs}
|
||||||
|
sequence={results.sequence}
|
||||||
|
customCategories={customCategories}
|
||||||
|
sessionEndTs={results.sessionEndTs}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Save metrics to daily status */}
|
||||||
|
{dailyStatusId && (
|
||||||
|
<div className="pt-3 border-t border-gray-100">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<button type="button" onClick={pushSummary} disabled={summaryPushing}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 transition-colors">
|
||||||
|
{summaryPushing ? 'Saving…' : summaryPushed ? '✓ Metrics saved to daily status' : 'Save metrics to daily status'}
|
||||||
|
</button>
|
||||||
|
{summaryError && <span className="text-xs text-red-500">{summaryError}</span>}
|
||||||
|
</div>
|
||||||
|
{!summaryPushed && (
|
||||||
|
<p className="text-[10px] text-gray-400 mt-1">
|
||||||
|
Saves success / failure counts, total attempts, and success rate to this day's record for cross-day and cross-subject graphing.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-2 border-t border-gray-100">
|
||||||
|
<Button variant="secondary" onClick={() => setPhase('classify')}>← Reclassify</Button>
|
||||||
|
<Button variant="secondary" onClick={reset}>Start over</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,25 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import Button from './ui/Button';
|
import Button from './ui/Button';
|
||||||
import FormField, { Input, Textarea } from './ui/FormField';
|
import { Input, Textarea } from './ui/FormField';
|
||||||
import Alert from './ui/Alert';
|
import Alert from './ui/Alert';
|
||||||
import { dailyStatusesApi } from '../api/client';
|
import { dailyStatusesApi, experimentsApi } from '../api/client';
|
||||||
|
|
||||||
const BUILTIN_KEYS = new Set(['experiment_description', 'vitals', 'treatment', 'notes']);
|
const BUILTIN_KEYS = new Set(['experiment_description', 'vitals', 'treatment', 'notes']);
|
||||||
|
|
||||||
|
// Maps field width % to a 12-column grid span
|
||||||
|
function widthToSpan(pct) {
|
||||||
|
if (pct <= 8) return 1;
|
||||||
|
if (pct <= 17) return 2;
|
||||||
|
if (pct <= 25) return 3;
|
||||||
|
if (pct <= 33) return 4;
|
||||||
|
if (pct <= 42) return 5;
|
||||||
|
if (pct <= 50) return 6;
|
||||||
|
if (pct <= 67) return 8;
|
||||||
|
if (pct <= 75) return 9;
|
||||||
|
return 12;
|
||||||
|
}
|
||||||
|
|
||||||
/** Extract the value for a field from a status record.
|
/** Extract the value for a field from a status record.
|
||||||
* Custom fields are keyed by fieldId (stable UUID), not by key (display slug). */
|
* Custom fields are keyed by fieldId (stable UUID), not by key (display slug). */
|
||||||
function getValue(status, field) {
|
function getValue(status, field) {
|
||||||
@@ -15,14 +28,14 @@ function getValue(status, field) {
|
|||||||
return status.custom_fields?.[field.fieldId] ?? '';
|
return status.custom_fields?.[field.fieldId] ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DailyStatusForm({ existing, animalId, template = [], onSuccess, onCancel }) {
|
export default function DailyStatusForm({ existing, animalId, experimentId, template = [], onTemplateUpdate, onSuccess, onCancel, onRequestDelete, hasPrev, hasNext, onNavigate }) {
|
||||||
const today = format(new Date(), 'yyyy-MM-dd');
|
const today = format(new Date(), 'yyyy-MM-dd');
|
||||||
|
|
||||||
// Active fields from template (date is always first and handled separately)
|
// Active fields from template (date is always first and handled separately)
|
||||||
const activeFields = template.filter((f) => f.active);
|
const activeFields = template.filter((f) => f.active);
|
||||||
|
|
||||||
function buildInitialValues() {
|
function buildInitialValues() {
|
||||||
const vals = { date: existing ? format(new Date(existing.date), 'yyyy-MM-dd') : today };
|
const vals = { date: existing ? String(existing.date).slice(0, 10) : today };
|
||||||
for (const f of activeFields) {
|
for (const f of activeFields) {
|
||||||
vals[f.key] = getValue(existing, f);
|
vals[f.key] = getValue(existing, f);
|
||||||
}
|
}
|
||||||
@@ -33,6 +46,7 @@ export default function DailyStatusForm({ existing, animalId, template = [], onS
|
|||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState({});
|
||||||
const [apiError, setApiError] = useState(null);
|
const [apiError, setApiError] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [savingTagFor, setSavingTagFor] = useState(null); // fieldId currently being saved
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setValues(buildInitialValues());
|
setValues(buildInitialValues());
|
||||||
@@ -42,6 +56,25 @@ export default function DailyStatusForm({ existing, animalId, template = [], onS
|
|||||||
|
|
||||||
const set = (key) => (e) => setValues((v) => ({ ...v, [key]: e.target.value }));
|
const set = (key) => (e) => setValues((v) => ({ ...v, [key]: e.target.value }));
|
||||||
|
|
||||||
|
async function saveTag(field) {
|
||||||
|
const value = (values[field.key] ?? '').trim();
|
||||||
|
if (!value || !experimentId || !onTemplateUpdate) return;
|
||||||
|
setSavingTagFor(field.fieldId);
|
||||||
|
try {
|
||||||
|
const updatedTemplate = template.map((f) =>
|
||||||
|
f.fieldId === field.fieldId
|
||||||
|
? { ...f, tags: [...(f.tags ?? []), value] }
|
||||||
|
: f
|
||||||
|
);
|
||||||
|
await experimentsApi.updateTemplate(experimentId, updatedTemplate);
|
||||||
|
onTemplateUpdate(updatedTemplate);
|
||||||
|
} catch (err) {
|
||||||
|
// non-critical — silently ignore tag save errors
|
||||||
|
} finally {
|
||||||
|
setSavingTagFor(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function validate() {
|
function validate() {
|
||||||
const e = {};
|
const e = {};
|
||||||
if (!values.date) {
|
if (!values.date) {
|
||||||
@@ -53,13 +86,7 @@ export default function DailyStatusForm({ existing, animalId, template = [], onS
|
|||||||
return Object.keys(e).length === 0;
|
return Object.keys(e).length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
async function doSave() {
|
||||||
e.preventDefault();
|
|
||||||
if (!validate()) return;
|
|
||||||
setLoading(true);
|
|
||||||
setApiError(null);
|
|
||||||
try {
|
|
||||||
// Separate builtin values from custom_fields
|
|
||||||
const builtin = {};
|
const builtin = {};
|
||||||
const custom = {};
|
const custom = {};
|
||||||
for (const f of activeFields) {
|
for (const f of activeFields) {
|
||||||
@@ -67,24 +94,38 @@ export default function DailyStatusForm({ existing, animalId, template = [], onS
|
|||||||
if (f.builtin) {
|
if (f.builtin) {
|
||||||
builtin[f.key] = val;
|
builtin[f.key] = val;
|
||||||
} else {
|
} else {
|
||||||
// Always key by fieldId so renaming or recreating a field never
|
|
||||||
// collides with or orphans data from a different field.
|
|
||||||
custom[f.fieldId] = val;
|
custom[f.fieldId] = val;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserve custom_fields for fields not in the current active template
|
|
||||||
// (e.g. a field was hidden after this record was created).
|
|
||||||
const existingCustom = existing?.custom_fields ?? {};
|
const existingCustom = existing?.custom_fields ?? {};
|
||||||
const mergedCustom = { ...existingCustom, ...custom };
|
const mergedCustom = { ...existingCustom, ...custom };
|
||||||
|
|
||||||
const payload = { date: values.date, ...builtin, custom_fields: mergedCustom };
|
const payload = { date: values.date, ...builtin, custom_fields: mergedCustom };
|
||||||
|
return existing
|
||||||
const result = existing
|
|
||||||
? await dailyStatusesApi.update(existing.id, payload)
|
? await dailyStatusesApi.update(existing.id, payload)
|
||||||
: await dailyStatusesApi.create({ ...payload, animal_id: animalId });
|
: await dailyStatusesApi.create({ ...payload, animal_id: animalId });
|
||||||
|
}
|
||||||
|
|
||||||
onSuccess(result);
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!validate()) return;
|
||||||
|
setLoading(true);
|
||||||
|
setApiError(null);
|
||||||
|
try {
|
||||||
|
onSuccess(await doSave());
|
||||||
|
} catch (err) {
|
||||||
|
setApiError(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNavigate(direction) {
|
||||||
|
if (!validate()) return;
|
||||||
|
setLoading(true);
|
||||||
|
setApiError(null);
|
||||||
|
try {
|
||||||
|
const result = await doSave();
|
||||||
|
onNavigate(direction, result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setApiError(err);
|
setApiError(err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -93,36 +134,62 @@ export default function DailyStatusForm({ existing, animalId, template = [], onS
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} noValidate className="space-y-4">
|
<form onSubmit={handleSubmit} noValidate>
|
||||||
{apiError && (
|
{apiError && (
|
||||||
<Alert type="error" message={apiError.message} details={apiError.details} onDismiss={() => setApiError(null)} />
|
<Alert type="error" message={apiError.message} details={apiError.details} onDismiss={() => setApiError(null)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Date — always present */}
|
<div className="grid grid-cols-12 gap-x-3 gap-y-2 mb-4">
|
||||||
<FormField label="Date" name="date" error={errors.date} required>
|
{/* Date — always full width */}
|
||||||
|
<div className="col-span-12 flex items-center gap-2">
|
||||||
|
<label className="text-xs font-medium text-gray-500 whitespace-nowrap w-28 shrink-0 text-right">Date <span className="text-red-400">*</span></label>
|
||||||
|
<div className="w-36">
|
||||||
<Input type="date" name="date" value={values.date} onChange={set('date')} required disabled={loading} />
|
<Input type="date" name="date" value={values.date} onChange={set('date')} required disabled={loading} />
|
||||||
</FormField>
|
{errors.date && <p className="text-xs text-red-500 mt-0.5">{errors.date}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Dynamic fields from template */}
|
{/* Dynamic fields */}
|
||||||
{activeFields.map((field) => (
|
{activeFields.map((field) => {
|
||||||
<FormField key={field.key} label={field.label} name={field.key}>
|
const isTextarea = field.type === 'textarea';
|
||||||
{field.type === 'textarea' ? (
|
const span = isTextarea ? 12 : widthToSpan(field.width ?? 100);
|
||||||
<Textarea
|
const fieldTags = field.tags ?? [];
|
||||||
name={field.key}
|
const currentValue = (values[field.key] ?? '').trim();
|
||||||
value={values[field.key] ?? ''}
|
const canSaveTag = experimentId && onTemplateUpdate && currentValue && !fieldTags.includes(currentValue) && !field.tagsDisabled;
|
||||||
onChange={set(field.key)}
|
return (
|
||||||
disabled={loading}
|
<div key={field.key} className={`col-span-${span} flex items-start gap-2`}>
|
||||||
/>
|
<label className="text-xs font-medium text-gray-500 whitespace-nowrap w-28 shrink-0 text-right pt-1.5" title={field.label}>
|
||||||
|
{field.abbr || field.label}{field.unit ? <span className="font-normal text-gray-400"> ({field.unit})</span> : null}
|
||||||
|
</label>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{isTextarea ? (
|
||||||
|
<Textarea name={field.key} value={values[field.key] ?? ''} onChange={set(field.key)} disabled={loading} />
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input name={field.key} value={values[field.key] ?? ''} onChange={set(field.key)} disabled={loading} />
|
||||||
name={field.key}
|
|
||||||
value={values[field.key] ?? ''}
|
|
||||||
onChange={set(field.key)}
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</FormField>
|
{(fieldTags.length > 0 || canSaveTag) && (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 mt-1">
|
||||||
|
{fieldTags.map((tag) => (
|
||||||
|
<button key={tag} type="button" title={`Quick-fill: ${tag}`}
|
||||||
|
onClick={() => setValues((v) => ({ ...v, [field.key]: tag }))}
|
||||||
|
className="inline-flex items-center px-2 py-0.5 rounded-full bg-indigo-50 border border-indigo-200 text-indigo-700 text-xs font-medium hover:bg-indigo-100 transition-colors">
|
||||||
|
{tag}
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
|
{canSaveTag && (
|
||||||
|
<button type="button" onClick={() => saveTag(field)} disabled={savingTagFor === field.fieldId}
|
||||||
|
title="Save current value as a quick-fill tag"
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-dashed border-indigo-300 text-indigo-500 text-xs hover:border-indigo-500 hover:text-indigo-700 transition-colors disabled:opacity-50">
|
||||||
|
{savingTagFor === field.fieldId ? '…' : '+ Save as tag'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
{activeFields.length === 0 && (
|
{activeFields.length === 0 && (
|
||||||
<p className="text-sm text-gray-400 italic text-center py-2">
|
<p className="text-sm text-gray-400 italic text-center py-2">
|
||||||
@@ -130,10 +197,31 @@ export default function DailyStatusForm({ existing, animalId, template = [], onS
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 pt-2">
|
<div className="flex items-center justify-between pt-2 border-t border-gray-100">
|
||||||
|
<div>
|
||||||
|
{existing && onRequestDelete && (
|
||||||
|
<Button variant="danger" type="button" onClick={onRequestDelete} disabled={loading}>Delete entry</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{existing && onNavigate && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button type="button" onClick={() => handleNavigate('prev')} disabled={loading || !hasPrev}
|
||||||
|
title="Save & go to previous entry"
|
||||||
|
className="px-2 py-1 rounded border border-gray-300 text-gray-500 text-sm hover:bg-gray-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors">
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => handleNavigate('next')} disabled={loading || !hasNext}
|
||||||
|
title="Save & go to next entry"
|
||||||
|
className="px-2 py-1 rounded border border-gray-300 text-gray-500 text-sm hover:bg-gray-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors">
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Button variant="secondary" type="button" onClick={onCancel} disabled={loading}>Cancel</Button>
|
<Button variant="secondary" type="button" onClick={onCancel} disabled={loading}>Cancel</Button>
|
||||||
<Button type="submit" loading={loading}>{existing ? 'Save Changes' : 'Log Status'}</Button>
|
<Button type="submit" loading={loading}>{existing ? 'Save Changes' : 'Log Status'}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
format, startOfMonth, endOfMonth, eachDayOfInterval,
|
||||||
|
getDay, addMonths, subMonths,
|
||||||
|
} from 'date-fns';
|
||||||
|
import { experimentsApi } from '../api/client';
|
||||||
|
import Modal from './ui/Modal';
|
||||||
|
|
||||||
|
// ── Day detail panel rendered inside modal ─────────────────────────────────────
|
||||||
|
|
||||||
|
function DayDetail({ statuses, animals, template }) {
|
||||||
|
const animalMap = useMemo(
|
||||||
|
() => Object.fromEntries(animals.map((a) => [a.id, a])),
|
||||||
|
[animals],
|
||||||
|
);
|
||||||
|
const activeFields = template.filter((f) => f.active);
|
||||||
|
|
||||||
|
function getVal(status, field) {
|
||||||
|
if (field.builtin) return status[field.key];
|
||||||
|
return status.custom_fields?.[field.fieldId];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statuses.length === 0) {
|
||||||
|
return <p className="text-gray-500 text-sm py-2">No data recorded for this day.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-1">
|
||||||
|
{statuses.map((status) => {
|
||||||
|
const animal = animalMap[status.animal_id];
|
||||||
|
const nonEmptyFields = activeFields.filter((f) => {
|
||||||
|
const v = getVal(status, f);
|
||||||
|
return v !== null && v !== undefined && String(v).trim() !== '';
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div key={status.id} className="bg-gray-50 rounded-xl p-4 border border-gray-100">
|
||||||
|
<div className="font-semibold text-gray-900 mb-2 text-sm">
|
||||||
|
{animal?.animal_name ?? 'Unknown subject'}
|
||||||
|
{animal?.animal_id_string && (
|
||||||
|
<span className="ml-2 text-gray-400 font-normal">· {animal.animal_id_string}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{nonEmptyFields.length === 0 ? (
|
||||||
|
<p className="text-xs text-gray-400">No field values recorded.</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1">
|
||||||
|
{nonEmptyFields.map((field) => (
|
||||||
|
<div key={field.fieldId ?? field.key} className="text-sm">
|
||||||
|
<span className="text-gray-500 font-medium">{field.label}:</span>{' '}
|
||||||
|
<span className="text-gray-900 whitespace-pre-wrap">{String(getVal(status, field))}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main calendar component ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function ExperimentCalendar({ experimentId, template, allStatuses, animals }) {
|
||||||
|
const [currentMonth, setCurrentMonth] = useState(() => new Date());
|
||||||
|
const [selectedField, setSelectedField] = useState(null);
|
||||||
|
const [calendarDays, setCalendarDays] = useState({});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selectedDate, setSelectedDate] = useState(null);
|
||||||
|
|
||||||
|
// Build field options from active template fields
|
||||||
|
const fieldOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
template
|
||||||
|
.filter((f) => f.active)
|
||||||
|
.map((f) => ({
|
||||||
|
value: f.builtin ? f.key : f.fieldId,
|
||||||
|
label: f.label,
|
||||||
|
})),
|
||||||
|
[template],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Default: first field whose label contains "weight", else first active field
|
||||||
|
useEffect(() => {
|
||||||
|
if (fieldOptions.length === 0 || selectedField) return;
|
||||||
|
const weightField = fieldOptions.find((f) =>
|
||||||
|
f.label.toLowerCase().includes('weight'),
|
||||||
|
);
|
||||||
|
setSelectedField(weightField ? weightField.value : fieldOptions[0].value);
|
||||||
|
}, [fieldOptions]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// Fetch calendar counts whenever field changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedField) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
experimentsApi
|
||||||
|
.getCalendar(experimentId, selectedField)
|
||||||
|
.then((data) => { if (!cancelled) setCalendarDays(data.days ?? {}); })
|
||||||
|
.catch(() => { if (!cancelled) setCalendarDays({}); })
|
||||||
|
.finally(() => { if (!cancelled) setLoading(false); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [experimentId, selectedField]);
|
||||||
|
|
||||||
|
// Build the grid: leading empty cells + each day of the month
|
||||||
|
const monthStart = startOfMonth(currentMonth);
|
||||||
|
const monthEnd = endOfMonth(currentMonth);
|
||||||
|
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
|
||||||
|
const leadingPad = getDay(monthStart); // 0 = Sunday
|
||||||
|
|
||||||
|
// Statuses filtered to the selected day
|
||||||
|
const selectedDayStatuses = useMemo(() => {
|
||||||
|
if (!selectedDate) return [];
|
||||||
|
const key = format(selectedDate, 'yyyy-MM-dd');
|
||||||
|
return allStatuses.filter((s) => s.date.slice(0, 10) === key);
|
||||||
|
}, [selectedDate, allStatuses]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm">
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3 mb-5">
|
||||||
|
<span className="text-sm text-gray-600 font-medium">Count by field:</span>
|
||||||
|
<select
|
||||||
|
data-testid="field-select"
|
||||||
|
className="text-sm border border-gray-300 rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white"
|
||||||
|
value={selectedField ?? ''}
|
||||||
|
onChange={(e) => setSelectedField(e.target.value)}
|
||||||
|
disabled={fieldOptions.length === 0}
|
||||||
|
>
|
||||||
|
{fieldOptions.map((f) => (
|
||||||
|
<option key={f.value} value={f.value}>{f.label}</option>
|
||||||
|
))}
|
||||||
|
{fieldOptions.length === 0 && <option value="">No fields configured</option>}
|
||||||
|
</select>
|
||||||
|
{loading && (
|
||||||
|
<span className="text-xs text-gray-400" aria-live="polite">Loading…</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Month navigation */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<button
|
||||||
|
aria-label="Previous month"
|
||||||
|
onClick={() => setCurrentMonth((m) => subMonths(m, 1))}
|
||||||
|
className="p-2 rounded-lg hover:bg-gray-100 text-gray-500 text-lg leading-none"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<h3 className="font-semibold text-gray-900 text-base">
|
||||||
|
{format(currentMonth, 'MMMM yyyy')}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
aria-label="Next month"
|
||||||
|
onClick={() => setCurrentMonth((m) => addMonths(m, 1))}
|
||||||
|
className="p-2 rounded-lg hover:bg-gray-100 text-gray-500 text-lg leading-none"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day-of-week headers */}
|
||||||
|
<div className="grid grid-cols-7 mb-1">
|
||||||
|
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((d) => (
|
||||||
|
<div key={d} className="text-center text-xs font-medium text-gray-400 py-1 select-none">
|
||||||
|
{d}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar grid */}
|
||||||
|
<div className="grid grid-cols-7 gap-1">
|
||||||
|
{Array.from({ length: leadingPad }).map((_, i) => (
|
||||||
|
<div key={`pad-${i}`} />
|
||||||
|
))}
|
||||||
|
{days.map((day) => {
|
||||||
|
const dateStr = format(day, 'yyyy-MM-dd');
|
||||||
|
const count = calendarDays[dateStr] ?? 0;
|
||||||
|
const hasData = count > 0;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={dateStr}
|
||||||
|
data-testid={`day-${dateStr}`}
|
||||||
|
disabled={!hasData}
|
||||||
|
onClick={() => setSelectedDate(day)}
|
||||||
|
className={[
|
||||||
|
'relative flex flex-col items-center justify-center rounded-lg py-1.5 min-h-[2.75rem] text-sm select-none transition-all',
|
||||||
|
hasData
|
||||||
|
? 'bg-blue-50 border border-blue-200 hover:bg-blue-100 cursor-pointer'
|
||||||
|
: 'text-gray-300 cursor-default',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<span className={hasData ? 'font-semibold text-blue-900 text-xs' : 'text-xs'}>
|
||||||
|
{day.getDate()}
|
||||||
|
</span>
|
||||||
|
{hasData && (
|
||||||
|
<span
|
||||||
|
data-testid={`count-${dateStr}`}
|
||||||
|
className="text-[10px] leading-none font-bold text-blue-600 mt-0.5"
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day detail modal */}
|
||||||
|
{selectedDate && (
|
||||||
|
<Modal
|
||||||
|
isOpen={!!selectedDate}
|
||||||
|
onClose={() => setSelectedDate(null)}
|
||||||
|
title={format(selectedDate, 'MMMM d, yyyy')}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<DayDetail
|
||||||
|
statuses={selectedDayStatuses}
|
||||||
|
animals={animals}
|
||||||
|
template={template}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
|
// ── Colors ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const BASE_COLORS = {
|
||||||
|
Success: { bg: 'bg-green-100', border: 'border-green-300', text: 'text-green-800', dot: 'bg-green-500', badge: 'bg-green-500' },
|
||||||
|
Failure: { bg: 'bg-red-100', border: 'border-red-300', text: 'text-red-800', dot: 'bg-red-500', badge: 'bg-red-500' },
|
||||||
|
Other: { bg: 'bg-gray-100', border: 'border-gray-300', text: 'text-gray-700', dot: 'bg-gray-400', badge: 'bg-gray-400' },
|
||||||
|
};
|
||||||
|
const EXTRA_COLORS = [
|
||||||
|
{ bg: 'bg-indigo-100', border: 'border-indigo-300', text: 'text-indigo-800', dot: 'bg-indigo-500', badge: 'bg-indigo-500' },
|
||||||
|
{ bg: 'bg-yellow-100', border: 'border-yellow-300', text: 'text-yellow-800', dot: 'bg-yellow-500', badge: 'bg-yellow-500' },
|
||||||
|
{ bg: 'bg-purple-100', border: 'border-purple-300', text: 'text-purple-800', dot: 'bg-purple-500', badge: 'bg-purple-500' },
|
||||||
|
{ bg: 'bg-pink-100', border: 'border-pink-300', text: 'text-pink-800', dot: 'bg-pink-500', badge: 'bg-pink-500' },
|
||||||
|
];
|
||||||
|
function getColors(cat, customCats = []) {
|
||||||
|
if (BASE_COLORS[cat]) return BASE_COLORS[cat];
|
||||||
|
return EXTRA_COLORS[Math.max(0, customCats.indexOf(cat)) % EXTRA_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ORDINALS = ['1st', '2nd', '3rd', '4th', '5th'];
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Build display items from a sequence slice, respecting mode + collapsibility. */
|
||||||
|
function buildItems(seqSlice, mode, collapsible) {
|
||||||
|
const categorised = seqSlice.filter(s => s.category);
|
||||||
|
if (mode === 'discrete') {
|
||||||
|
return categorised.map((s, i) => ({ id: i, category: s.category, count: 1 }));
|
||||||
|
}
|
||||||
|
// Rebuild runs within this slice, then apply collapsibility
|
||||||
|
const runs = [];
|
||||||
|
for (const s of categorised) {
|
||||||
|
if (runs.length === 0 || runs[runs.length - 1].category !== s.category) {
|
||||||
|
runs.push({ category: s.category, length: 1 });
|
||||||
|
} else {
|
||||||
|
runs[runs.length - 1].length++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const items = [];
|
||||||
|
for (const run of runs) {
|
||||||
|
if (collapsible[run.category] === false) {
|
||||||
|
for (let i = 0; i < run.length; i++) items.push({ id: items.length, category: run.category, count: 1 });
|
||||||
|
} else {
|
||||||
|
items.push({ id: items.length, category: run.category, count: run.length });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute per-category counts and total for a sequence slice. */
|
||||||
|
function segStats(seqSlice) {
|
||||||
|
const byCat = {};
|
||||||
|
for (const s of seqSlice) {
|
||||||
|
if (!s.category) continue;
|
||||||
|
byCat[s.category] = (byCat[s.category] || 0) + 1;
|
||||||
|
}
|
||||||
|
const total = Object.values(byCat).reduce((a, b) => a + b, 0);
|
||||||
|
return { byCat, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props:
|
||||||
|
* runs {category, length}[] — global run-length-encoded sequence
|
||||||
|
* sequence {note, category, timestamp}[]
|
||||||
|
* customCategories string[]
|
||||||
|
*/
|
||||||
|
export default function RunSequenceView({ runs = [], sequence = [], customCategories = [], sessionEndTs = null }) {
|
||||||
|
const [mode, setMode] = useState('grouped');
|
||||||
|
const [collapsible, setCollapsible] = useState({});
|
||||||
|
const [splitN, setSplitN] = useState(1);
|
||||||
|
const [durationMins, setDurationMins] = useState(20);
|
||||||
|
const [selectedSegment, setSelectedSegment] = useState(null);
|
||||||
|
|
||||||
|
// Initialise collapsibility for any new categories
|
||||||
|
const allCats = useMemo(() => [...new Set(runs.map(r => r.category))], [runs]);
|
||||||
|
useEffect(() => {
|
||||||
|
setCollapsible(prev => {
|
||||||
|
const next = { ...prev };
|
||||||
|
for (const cat of allCats) {
|
||||||
|
if (!(cat in next)) next[cat] = cat !== 'Success';
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [allCats]);
|
||||||
|
|
||||||
|
// Reset selected segment when split changes
|
||||||
|
useEffect(() => { setSelectedSegment(null); }, [splitN]);
|
||||||
|
|
||||||
|
// ── Timestamp bounds ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const { startTs, segSize, windowUnderrun } = useMemo(() => {
|
||||||
|
let endT = sessionEndTs != null ? sessionEndTs : null;
|
||||||
|
if (endT === null) {
|
||||||
|
const ts = sequence.map(s => parseFloat(s.timestamp)).filter(t => !isNaN(t));
|
||||||
|
endT = ts.length ? Math.max(...ts) : durationMins * 60;
|
||||||
|
}
|
||||||
|
const rawStart = endT - durationMins * 60;
|
||||||
|
return {
|
||||||
|
startTs: rawStart,
|
||||||
|
segSize: (durationMins * 60) / Math.max(splitN, 1),
|
||||||
|
windowUnderrun: rawStart < 0 ? Math.abs(rawStart) : 0,
|
||||||
|
};
|
||||||
|
}, [sequence, sessionEndTs, durationMins, splitN]);
|
||||||
|
|
||||||
|
function segIndexOf(timestamp) {
|
||||||
|
const t = parseFloat(timestamp);
|
||||||
|
if (isNaN(t)) return 0;
|
||||||
|
return Math.min(Math.max(Math.floor((t - startTs) / segSize), 0), splitN - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Segment data (only when split > 1) ───────────────────────────────────────
|
||||||
|
|
||||||
|
const segmentData = useMemo(() => {
|
||||||
|
if (splitN === 1) return null;
|
||||||
|
const groups = Array.from({ length: splitN }, () => []);
|
||||||
|
for (const s of sequence) {
|
||||||
|
groups[segIndexOf(s.timestamp)].push(s);
|
||||||
|
}
|
||||||
|
return groups.map((grp, i) => ({
|
||||||
|
index: i,
|
||||||
|
items: buildItems(grp, mode, collapsible),
|
||||||
|
stats: segStats(grp),
|
||||||
|
}));
|
||||||
|
}, [splitN, sequence, mode, collapsible, startTs, segSize]);
|
||||||
|
|
||||||
|
// ── Single-segment display items (split = 1) ─────────────────────────────────
|
||||||
|
|
||||||
|
const singleItems = useMemo(() => {
|
||||||
|
if (splitN !== 1) return [];
|
||||||
|
if (mode === 'discrete') {
|
||||||
|
const src = sequence.length > 0
|
||||||
|
? sequence.filter(s => s.category)
|
||||||
|
: runs.flatMap(r => Array.from({ length: r.length }, () => ({ category: r.category })));
|
||||||
|
return src.map((s, i) => ({ id: i, category: s.category, count: 1 }));
|
||||||
|
}
|
||||||
|
const items = [];
|
||||||
|
for (const run of runs) {
|
||||||
|
if (collapsible[run.category] === false) {
|
||||||
|
for (let i = 0; i < run.length; i++) items.push({ id: items.length, category: run.category, count: 1 });
|
||||||
|
} else {
|
||||||
|
items.push({ id: items.length, category: run.category, count: run.length });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}, [splitN, mode, runs, sequence, collapsible]);
|
||||||
|
|
||||||
|
// ── Global totals ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const globalStats = useMemo(() => segStats(sequence), [sequence]);
|
||||||
|
|
||||||
|
if (runs.length === 0) return null;
|
||||||
|
|
||||||
|
const hasTimestamps = sequence.some(s => !isNaN(parseFloat(s.timestamp)));
|
||||||
|
const activeSegStats = selectedSegment !== null && segmentData ? segmentData[selectedSegment]?.stats ?? null : null;
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* ── Controls row ── */}
|
||||||
|
<div className="flex items-center flex-wrap gap-x-4 gap-y-1.5 mb-2">
|
||||||
|
{/* Title */}
|
||||||
|
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||||
|
Run sequence ({runs.length} runs)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Mode toggle */}
|
||||||
|
<div className="flex rounded border border-gray-200 overflow-hidden text-xs">
|
||||||
|
<button type="button" onClick={() => setMode('discrete')}
|
||||||
|
className={`px-2 py-0.5 transition-colors ${mode === 'discrete' ? 'bg-gray-700 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
|
||||||
|
Discrete
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setMode('grouped')}
|
||||||
|
className={`px-2 py-0.5 border-l border-gray-200 transition-colors ${mode === 'grouped' ? 'bg-gray-700 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
|
||||||
|
Grouped
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time split */}
|
||||||
|
{hasTimestamps && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
|
<span className="text-gray-400">Split:</span>
|
||||||
|
<select value={splitN} onChange={e => setSplitN(Number(e.target.value))}
|
||||||
|
className="border border-gray-200 rounded px-1.5 py-0.5 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400">
|
||||||
|
<option value={1}>None</option>
|
||||||
|
<option value={2}>½</option>
|
||||||
|
<option value={3}>⅓</option>
|
||||||
|
<option value={4}>¼</option>
|
||||||
|
<option value={5}>⅕</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="number" min={1} max={600} value={durationMins}
|
||||||
|
onChange={e => setDurationMins(Math.max(1, Number(e.target.value)))}
|
||||||
|
className="border border-gray-200 rounded px-1.5 py-0.5 w-14 text-xs text-right focus:outline-none focus:ring-1 focus:ring-indigo-400"
|
||||||
|
title="Experiment duration in minutes"
|
||||||
|
/>
|
||||||
|
<span className="text-gray-400">min</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Per-category collapse toggles (grouped mode) ── */}
|
||||||
|
{mode === 'grouped' && allCats.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||||
|
{allCats.map(cat => {
|
||||||
|
const c = getColors(cat, customCategories);
|
||||||
|
const isGrouped = collapsible[cat] !== false;
|
||||||
|
return (
|
||||||
|
<button key={cat} type="button"
|
||||||
|
onClick={() => setCollapsible(prev => ({ ...prev, [cat]: !prev[cat] }))}
|
||||||
|
title={isGrouped ? 'Grouped — click for discrete' : 'Discrete — click to group'}
|
||||||
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs border transition-colors
|
||||||
|
${isGrouped ? `${c.bg} ${c.border} ${c.text}` : `bg-white ${c.border} ${c.text} opacity-70`}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${c.dot}`} />
|
||||||
|
{cat}: {isGrouped ? 'grouped' : 'discrete'}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Underrun warning ── */}
|
||||||
|
{splitN > 1 && windowUnderrun > 0 && (() => {
|
||||||
|
const u = Math.round(windowUnderrun);
|
||||||
|
const m = Math.floor(u / 60), s = u % 60;
|
||||||
|
return (
|
||||||
|
<p className="text-[10px] text-amber-600 bg-amber-50 border border-amber-200 rounded px-2 py-1 mb-2">
|
||||||
|
The {durationMins}-min window starts {m}:{String(s).padStart(2,'0')} before the recording — the 1st segment includes unrecorded time.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* ── Segment buttons (when split > 1) ── */}
|
||||||
|
{splitN > 1 && segmentData && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||||
|
{segmentData.map((seg, si) => {
|
||||||
|
const isActive = selectedSegment === si;
|
||||||
|
const segStart = startTs + si * segSize;
|
||||||
|
const segEnd = startTs + (si + 1) * segSize;
|
||||||
|
const fmt = s => {
|
||||||
|
const t = Math.round(s);
|
||||||
|
const clamped = Math.max(0, t);
|
||||||
|
const m = Math.floor(clamped / 60);
|
||||||
|
const sec = clamped % 60;
|
||||||
|
const prefix = t < 0 ? '0:00*' : `${m}:${String(sec).padStart(2, '0')}`;
|
||||||
|
return prefix;
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<button key={si} type="button"
|
||||||
|
onClick={() => setSelectedSegment(prev => prev === si ? null : si)}
|
||||||
|
title={`${fmt(segStart)} – ${fmt(segEnd)}`}
|
||||||
|
className={`inline-flex flex-col items-center px-2.5 py-1 rounded-lg text-xs font-medium border transition-all
|
||||||
|
${isActive ? 'bg-gray-800 text-white border-gray-800' : 'bg-white text-gray-600 border-gray-300 hover:border-gray-400'}`}>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
{ORDINALS[si]}
|
||||||
|
<span className={`text-[10px] px-1 py-px rounded-full font-semibold
|
||||||
|
${isActive ? 'bg-white/20 text-white' : 'bg-gray-100 text-gray-500'}`}>
|
||||||
|
{seg.stats.total}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className={`text-[9px] font-normal tabular-nums ${isActive ? 'text-gray-300' : 'text-gray-400'}`}>
|
||||||
|
{fmt(segStart)}–{fmt(segEnd)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{selectedSegment !== null && (
|
||||||
|
<button type="button" onClick={() => setSelectedSegment(null)}
|
||||||
|
className="text-xs text-gray-400 hover:text-gray-600 px-1">✕</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Selected segment stats ── */}
|
||||||
|
{activeSegStats && (
|
||||||
|
<div className="flex flex-wrap items-center gap-3 mb-2 px-3 py-2 bg-gray-50 rounded-lg border border-gray-200 text-xs">
|
||||||
|
<span className="font-semibold text-gray-700">{ORDINALS[selectedSegment]} segment</span>
|
||||||
|
<span className="text-gray-500">{activeSegStats.total} attempt{activeSegStats.total !== 1 ? 's' : ''}</span>
|
||||||
|
{Object.entries(activeSegStats.byCat).sort(([, a], [, b]) => b - a).map(([cat, n]) => {
|
||||||
|
const c = getColors(cat, customCategories);
|
||||||
|
return (
|
||||||
|
<span key={cat} className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full ${c.bg} ${c.text}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${c.dot}`} />
|
||||||
|
{cat}: {n}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Chip area ── */}
|
||||||
|
<div className="flex flex-wrap gap-1 items-center">
|
||||||
|
{splitN === 1
|
||||||
|
? singleItems.map(item => <Chip key={item.id} item={item} customCategories={customCategories} dimmed={false} />)
|
||||||
|
: segmentData && segmentData.map((seg, si) => (
|
||||||
|
<React.Fragment key={si}>
|
||||||
|
{si > 0 && (
|
||||||
|
<div className="flex flex-col items-center mx-0.5 self-stretch" aria-hidden>
|
||||||
|
<div className="w-px flex-1 bg-gray-300" style={{ minHeight: 20 }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{seg.items.length === 0
|
||||||
|
? <span className="text-[10px] text-gray-300 italic px-1">empty</span>
|
||||||
|
: seg.items.map(item => (
|
||||||
|
<Chip key={`${si}-${item.id}`} item={item} customCategories={customCategories}
|
||||||
|
dimmed={selectedSegment !== null && selectedSegment !== si} />
|
||||||
|
))}
|
||||||
|
</React.Fragment>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Global totals ── */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3 mt-2 pt-2 border-t border-gray-100 text-xs text-gray-500">
|
||||||
|
<span className="font-medium text-gray-700">Total: {globalStats.total} attempt{globalStats.total !== 1 ? 's' : ''}</span>
|
||||||
|
{Object.entries(globalStats.byCat).sort(([, a], [, b]) => b - a).map(([cat, n]) => {
|
||||||
|
const c = getColors(cat, customCategories);
|
||||||
|
return (
|
||||||
|
<span key={cat} className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full ${c.bg} ${c.text}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${c.dot}`} />
|
||||||
|
{cat}: {n}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Chip({ item, customCategories, dimmed }) {
|
||||||
|
const c = getColors(item.category, customCategories);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
title={item.count > 1 ? `${item.category} × ${item.count}` : item.category}
|
||||||
|
className={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono border transition-opacity
|
||||||
|
${c.bg} ${c.text} ${c.border} ${dimmed ? 'opacity-25' : 'opacity-100'}`}>
|
||||||
|
{item.count > 1 ? `${item.count}×` : ''}{item.category.slice(0, 3)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import Button from './ui/Button';
|
||||||
|
import { Input, Textarea } from './ui/FormField';
|
||||||
|
|
||||||
|
function widthToSpan(pct) {
|
||||||
|
if (pct <= 8) return 1;
|
||||||
|
if (pct <= 17) return 2;
|
||||||
|
if (pct <= 25) return 3;
|
||||||
|
if (pct <= 33) return 4;
|
||||||
|
if (pct <= 42) return 5;
|
||||||
|
if (pct <= 50) return 6;
|
||||||
|
if (pct <= 67) return 8;
|
||||||
|
if (pct <= 75) return 9;
|
||||||
|
return 12;
|
||||||
|
}
|
||||||
|
import Alert from './ui/Alert';
|
||||||
|
import { animalsApi, experimentsApi } from '../api/client';
|
||||||
|
|
||||||
|
export default function SubjectInfoForm({ animal, subjectTemplate = [], experimentId, onTemplateUpdate, onSaved, onCancel }) {
|
||||||
|
const activeFields = subjectTemplate.filter((f) => f.active);
|
||||||
|
|
||||||
|
function buildInitialValues() {
|
||||||
|
const vals = {};
|
||||||
|
for (const f of activeFields) {
|
||||||
|
vals[f.key] = animal?.subject_info?.[f.fieldId] ?? '';
|
||||||
|
}
|
||||||
|
return vals;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [values, setValues] = useState(buildInitialValues);
|
||||||
|
const [apiError, setApiError] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [savingTagFor, setSavingTagFor] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setValues(buildInitialValues());
|
||||||
|
setApiError(null);
|
||||||
|
}, [animal, subjectTemplate]);
|
||||||
|
|
||||||
|
const set = (key) => (e) => setValues((v) => ({ ...v, [key]: e.target.value }));
|
||||||
|
|
||||||
|
async function saveTag(field) {
|
||||||
|
const value = (values[field.key] ?? '').trim();
|
||||||
|
if (!value || !experimentId || !onTemplateUpdate) return;
|
||||||
|
setSavingTagFor(field.fieldId);
|
||||||
|
try {
|
||||||
|
const updatedTemplate = subjectTemplate.map((f) =>
|
||||||
|
f.fieldId === field.fieldId
|
||||||
|
? { ...f, tags: [...(f.tags ?? []), value] }
|
||||||
|
: f
|
||||||
|
);
|
||||||
|
await experimentsApi.updateSubjectTemplate(experimentId, updatedTemplate);
|
||||||
|
onTemplateUpdate(updatedTemplate);
|
||||||
|
} catch (_) {
|
||||||
|
} finally {
|
||||||
|
setSavingTagFor(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setApiError(null);
|
||||||
|
try {
|
||||||
|
// Preserve any fields not in the current active template
|
||||||
|
const existing = animal?.subject_info ?? {};
|
||||||
|
const updates = {};
|
||||||
|
for (const f of activeFields) {
|
||||||
|
updates[f.fieldId] = values[f.key] || null;
|
||||||
|
}
|
||||||
|
const merged = { ...existing, ...updates };
|
||||||
|
|
||||||
|
const updated = await animalsApi.update(animal.id, { subject_info: merged });
|
||||||
|
onSaved(updated);
|
||||||
|
} catch (err) {
|
||||||
|
setApiError(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeFields.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-6">
|
||||||
|
<p className="text-sm text-gray-400 italic">No subject info fields configured for this experiment.</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">Use "Subject Template" on the experiment page to add fields.</p>
|
||||||
|
<div className="flex justify-end mt-4">
|
||||||
|
<Button variant="secondary" type="button" onClick={onCancel}>Close</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} noValidate>
|
||||||
|
{apiError && (
|
||||||
|
<Alert type="error" message={apiError.message} details={apiError.details} onDismiss={() => setApiError(null)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-12 gap-x-3 gap-y-2 mb-4">
|
||||||
|
{activeFields.map((field) => {
|
||||||
|
const isTextarea = field.type === 'textarea';
|
||||||
|
const span = isTextarea ? 12 : widthToSpan(field.width ?? 100);
|
||||||
|
const fieldTags = field.tags ?? [];
|
||||||
|
const currentValue = (values[field.key] ?? '').trim();
|
||||||
|
const canSaveTag = experimentId && onTemplateUpdate && currentValue && !fieldTags.includes(currentValue) && !field.tagsDisabled;
|
||||||
|
return (
|
||||||
|
<div key={field.fieldId} className={`col-span-${span} flex items-start gap-2`}>
|
||||||
|
<label className="text-xs font-medium text-gray-500 whitespace-nowrap w-28 shrink-0 text-right pt-1.5">{field.label}</label>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{isTextarea ? (
|
||||||
|
<Textarea name={field.key} value={values[field.key] ?? ''} onChange={set(field.key)} disabled={loading} />
|
||||||
|
) : (
|
||||||
|
<Input name={field.key} value={values[field.key] ?? ''} onChange={set(field.key)} disabled={loading} />
|
||||||
|
)}
|
||||||
|
{(fieldTags.length > 0 || canSaveTag) && (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 mt-1">
|
||||||
|
{fieldTags.map((tag) => (
|
||||||
|
<button key={tag} type="button" title={`Quick-fill: ${tag}`}
|
||||||
|
onClick={() => setValues((v) => ({ ...v, [field.key]: tag }))}
|
||||||
|
className="inline-flex items-center px-2 py-0.5 rounded-full bg-indigo-50 border border-indigo-200 text-indigo-700 text-xs font-medium hover:bg-indigo-100 transition-colors">
|
||||||
|
{tag}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{canSaveTag && (
|
||||||
|
<button type="button" onClick={() => saveTag(field)} disabled={savingTagFor === field.fieldId}
|
||||||
|
title="Save current value as a quick-fill tag"
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-dashed border-indigo-300 text-indigo-500 text-xs hover:border-indigo-500 hover:text-indigo-700 transition-colors disabled:opacity-50">
|
||||||
|
{savingTagFor === field.fieldId ? '…' : '+ Save as tag'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-2 border-t border-gray-100">
|
||||||
|
<Button variant="secondary" type="button" onClick={onCancel} disabled={loading}>Cancel</Button>
|
||||||
|
<Button type="submit" loading={loading}>Save</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,21 +1,111 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { experimentsApi } from '../api/client';
|
import { experimentsApi } from '../api/client';
|
||||||
import Button from './ui/Button';
|
import Button from './ui/Button';
|
||||||
import Alert from './ui/Alert';
|
import Alert from './ui/Alert';
|
||||||
|
|
||||||
// Slugify a label into a valid field key
|
// ── helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function toKey(label) {
|
function toKey(label) {
|
||||||
return label
|
return label.toLowerCase().trim()
|
||||||
.toLowerCase()
|
.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 64);
|
||||||
.trim()
|
|
||||||
.replace(/[^a-z0-9]+/g, '_')
|
|
||||||
.replace(/^_+|_+$/g, '')
|
|
||||||
.slice(0, 64);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function FieldRow({ field, onChange, onToggle, onRemove, allKeys }) {
|
// ── Tag chip ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TagChip({ value, onRemove }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-indigo-50 border border-indigo-200 text-indigo-700 text-xs font-medium">
|
||||||
|
{value}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Remove tag ${value}`}
|
||||||
|
onClick={onRemove}
|
||||||
|
className="text-indigo-400 hover:text-indigo-700 leading-none"
|
||||||
|
>✕</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inline tag editor (expanded panel below a field row) ──────────────────────
|
||||||
|
|
||||||
|
function TagPanel({ field, onChange, onAddTag, onRemoveTag }) {
|
||||||
|
const [draft, setDraft] = useState('');
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
const disabled = !!field.tagsDisabled;
|
||||||
|
|
||||||
|
function commit() {
|
||||||
|
const v = draft.trim();
|
||||||
|
if (!v) { setErr('Tag cannot be empty'); return; }
|
||||||
|
if (v.length > 512) { setErr('Tag must be ≤512 characters'); return; }
|
||||||
|
if ((field.tags ?? []).includes(v)) { setErr('Tag already exists'); return; }
|
||||||
|
setErr('');
|
||||||
|
onAddTag(field.fieldId, v);
|
||||||
|
setDraft('');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-4 pb-3 pt-2 bg-indigo-50/40 border-t border-indigo-100 rounded-b-lg">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<p className="text-xs font-semibold text-indigo-600 uppercase tracking-wide">Quick-fill tags</p>
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-gray-500 cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={disabled}
|
||||||
|
onChange={(e) => onChange({ ...field, tagsDisabled: e.target.checked })}
|
||||||
|
className="w-3.5 h-3.5 accent-indigo-600"
|
||||||
|
/>
|
||||||
|
Disable tagging
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!disabled && (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-2 min-h-[24px]">
|
||||||
|
{(field.tags ?? []).length === 0 && (
|
||||||
|
<span className="text-xs text-gray-400 italic">No tags yet.</span>
|
||||||
|
)}
|
||||||
|
{(field.tags ?? []).map((t) => (
|
||||||
|
<TagChip key={t} value={t} onRemove={() => onRemoveTag(field.fieldId, t)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 items-start">
|
||||||
|
<div className="flex-1">
|
||||||
|
<input
|
||||||
|
aria-label={`Add tag for ${field.label}`}
|
||||||
|
className={`w-full text-sm rounded border px-2 py-1 focus:outline-none focus:ring-2 focus:ring-indigo-400 ${err ? 'border-red-400' : 'border-gray-300'}`}
|
||||||
|
placeholder="Type a tag value…"
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => { setDraft(e.target.value); setErr(''); }}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commit(); } }}
|
||||||
|
/>
|
||||||
|
{err && <p className="text-xs text-red-500 mt-0.5">{err}</p>}
|
||||||
|
</div>
|
||||||
|
<Button size="sm" type="button" onClick={commit}>+ Add</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{disabled && (
|
||||||
|
<p className="text-xs text-gray-400 italic">Tagging is disabled for this field. Existing tags are preserved.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Single field row (draggable) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
function FieldRow({
|
||||||
|
field, index, total,
|
||||||
|
onChange, onToggle, onRemove, onAddTag, onRemoveTag,
|
||||||
|
allKeys,
|
||||||
|
// drag props
|
||||||
|
onDragStart, onDragEnter, onDragEnd,
|
||||||
|
isDragOver,
|
||||||
|
}) {
|
||||||
const [labelDraft, setLabelDraft] = useState(field.label);
|
const [labelDraft, setLabelDraft] = useState(field.label);
|
||||||
const [labelError, setLabelError] = useState('');
|
const [labelError, setLabelError] = useState('');
|
||||||
|
const [showTags, setShowTags] = useState(false);
|
||||||
|
|
||||||
useEffect(() => { setLabelDraft(field.label); }, [field.label]);
|
useEffect(() => { setLabelDraft(field.label); }, [field.label]);
|
||||||
|
|
||||||
@@ -23,23 +113,36 @@ function FieldRow({ field, onChange, onToggle, onRemove, allKeys }) {
|
|||||||
const trimmed = labelDraft.trim();
|
const trimmed = labelDraft.trim();
|
||||||
if (!trimmed) { setLabelError('Label cannot be empty'); return; }
|
if (!trimmed) { setLabelError('Label cannot be empty'); return; }
|
||||||
if (trimmed.length > 128) { setLabelError('Label must be ≤128 characters'); return; }
|
if (trimmed.length > 128) { setLabelError('Label must be ≤128 characters'); return; }
|
||||||
|
|
||||||
// For custom fields, also ensure the derived key is unique
|
|
||||||
if (!field.builtin) {
|
if (!field.builtin) {
|
||||||
const newKey = toKey(trimmed);
|
const newKey = toKey(trimmed);
|
||||||
if (!newKey) { setLabelError('Label must contain at least one alphanumeric character'); return; }
|
if (!newKey) { setLabelError('Label must contain at least one alphanumeric character'); return; }
|
||||||
const duplicate = allKeys.find((k) => k !== field.key && k === newKey);
|
if (allKeys.find((k) => k !== field.key && k === newKey)) {
|
||||||
if (duplicate) { setLabelError('A field with this name already exists'); return; }
|
setLabelError('A field with this name already exists'); return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setLabelError('');
|
setLabelError('');
|
||||||
onChange({ ...field, label: trimmed, key: field.builtin ? field.key : toKey(trimmed) });
|
onChange({ ...field, label: trimmed, key: field.builtin ? field.key : toKey(trimmed) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tagCount = (field.tags ?? []).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors ${field.active ? 'bg-white border-gray-200' : 'bg-gray-50 border-gray-100 opacity-60'}`}>
|
<div
|
||||||
{/* Drag handle placeholder (visual only) */}
|
className={`rounded-lg border transition-all ${isDragOver ? 'border-blue-400 shadow-md scale-[1.01]' : 'border-gray-200'} ${field.active ? 'bg-white' : 'bg-gray-50 opacity-60'}`}
|
||||||
<span className="text-gray-300 cursor-default select-none text-lg leading-none">⠿</span>
|
draggable
|
||||||
|
onDragStart={() => onDragStart(index)}
|
||||||
|
onDragEnter={() => onDragEnter(index)}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
{/* Main row */}
|
||||||
|
<div className="flex items-center gap-3 px-3 py-2.5">
|
||||||
|
{/* Drag handle */}
|
||||||
|
<span
|
||||||
|
className="text-gray-300 hover:text-gray-500 cursor-grab active:cursor-grabbing select-none text-lg leading-none"
|
||||||
|
title="Drag to reorder"
|
||||||
|
aria-label="Drag handle"
|
||||||
|
>⠿</span>
|
||||||
|
|
||||||
{/* Label input */}
|
{/* Label input */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -63,7 +166,7 @@ function FieldRow({ field, onChange, onToggle, onRemove, allKeys }) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Type badge */}
|
{/* Type selector */}
|
||||||
<select
|
<select
|
||||||
aria-label={`Field type for ${field.key}`}
|
aria-label={`Field type for ${field.key}`}
|
||||||
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-blue-400 disabled:bg-gray-100"
|
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-blue-400 disabled:bg-gray-100"
|
||||||
@@ -75,11 +178,78 @@ function FieldRow({ field, onChange, onToggle, onRemove, allKeys }) {
|
|||||||
<option value="textarea">Long text</option>
|
<option value="textarea">Long text</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
{/* Toggle active */}
|
{/* Width (text fields only) */}
|
||||||
|
{field.type === 'text' && (
|
||||||
|
<select
|
||||||
|
aria-label={`Width for ${field.key}`}
|
||||||
|
title="Input width in the entry form"
|
||||||
|
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-blue-400 disabled:bg-gray-100"
|
||||||
|
value={field.width ?? 100}
|
||||||
|
disabled={!field.active}
|
||||||
|
onChange={(e) => onChange({ ...field, width: Number(e.target.value) })}
|
||||||
|
>
|
||||||
|
<option value={100}>Full</option>
|
||||||
|
<option value={75}>3/4</option>
|
||||||
|
<option value={67}>2/3</option>
|
||||||
|
<option value={50}>1/2</option>
|
||||||
|
<option value={42}>5/12</option>
|
||||||
|
<option value={33}>1/3</option>
|
||||||
|
<option value={25}>1/4</option>
|
||||||
|
<option value={17}>1/6</option>
|
||||||
|
<option value={8}>1/12</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Abbreviation */}
|
||||||
|
<input
|
||||||
|
aria-label={`Abbreviation for ${field.key}`}
|
||||||
|
className="text-xs border border-gray-200 rounded px-2 py-1 w-20 focus:outline-none focus:ring-1 focus:ring-blue-400 disabled:bg-gray-100"
|
||||||
|
placeholder="Abbr"
|
||||||
|
title="Column abbreviation (≤10 chars)"
|
||||||
|
maxLength={10}
|
||||||
|
value={field.abbr ?? ''}
|
||||||
|
disabled={!field.active}
|
||||||
|
onChange={(e) => onChange({ ...field, abbr: e.target.value })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Unit */}
|
||||||
|
<input
|
||||||
|
aria-label={`Unit for ${field.key}`}
|
||||||
|
className="text-xs border border-gray-200 rounded px-2 py-1 w-20 focus:outline-none focus:ring-1 focus:ring-blue-400 disabled:bg-gray-100"
|
||||||
|
placeholder="Unit"
|
||||||
|
title="Measurement unit (≤10 chars)"
|
||||||
|
maxLength={10}
|
||||||
|
value={field.unit ?? ''}
|
||||||
|
disabled={!field.active}
|
||||||
|
onChange={(e) => onChange({ ...field, unit: e.target.value })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Tags toggle */}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
title={showTags ? 'Hide tags' : 'Manage quick-fill tags'}
|
||||||
|
aria-label={`Manage tags for ${field.label}`}
|
||||||
|
onClick={() => setShowTags((v) => !v)}
|
||||||
|
className={`relative text-sm px-2 py-1 rounded border transition-colors ${
|
||||||
|
showTags
|
||||||
|
? 'border-indigo-400 bg-indigo-50 text-indigo-700'
|
||||||
|
: 'border-gray-200 text-gray-500 hover:bg-indigo-50 hover:border-indigo-300 hover:text-indigo-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
🏷
|
||||||
|
{tagCount > 0 && (
|
||||||
|
<span className="absolute -top-1.5 -right-1.5 bg-indigo-600 text-white text-[10px] font-bold rounded-full w-4 h-4 flex items-center justify-center leading-none">
|
||||||
|
{tagCount > 9 ? '9+' : tagCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Hide / Show */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
title={field.active ? 'Hide field' : 'Show field'}
|
title={field.active ? 'Hide field' : 'Show field'}
|
||||||
aria-label={field.active ? `Hide field ${field.label}` : `Show field ${field.label}`}
|
aria-label={field.active ? `Hide ${field.label}` : `Show ${field.label}`}
|
||||||
onClick={() => onToggle(field.key)}
|
onClick={() => onToggle(field.fieldId)}
|
||||||
className={`text-sm px-2 py-1 rounded border transition-colors ${
|
className={`text-sm px-2 py-1 rounded border transition-colors ${
|
||||||
field.active
|
field.active
|
||||||
? 'border-gray-200 text-gray-500 hover:bg-yellow-50 hover:border-yellow-300 hover:text-yellow-700'
|
? 'border-gray-200 text-gray-500 hover:bg-yellow-50 hover:border-yellow-300 hover:text-yellow-700'
|
||||||
@@ -89,49 +259,102 @@ function FieldRow({ field, onChange, onToggle, onRemove, allKeys }) {
|
|||||||
{field.active ? 'Hide' : 'Show'}
|
{field.active ? 'Hide' : 'Show'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Remove — only for custom fields */}
|
{/* Remove (custom fields only) */}
|
||||||
{!field.builtin && (
|
{!field.builtin && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
title="Remove field permanently"
|
title="Remove field permanently"
|
||||||
aria-label={`Remove field ${field.label}`}
|
aria-label={`Remove ${field.label}`}
|
||||||
onClick={() => onRemove(field.key)}
|
onClick={() => onRemove(field.fieldId)}
|
||||||
className="text-sm px-2 py-1 rounded border border-red-200 text-red-500 hover:bg-red-50 transition-colors"
|
className="text-sm px-2 py-1 rounded border border-red-200 text-red-500 hover:bg-red-50 transition-colors"
|
||||||
>
|
>✕</button>
|
||||||
✕
|
)}
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
|
{/* Tag panel (collapsible) */}
|
||||||
|
{showTags && (
|
||||||
|
<TagPanel field={field} onChange={onChange} onAddTag={onAddTag} onRemoveTag={onRemoveTag} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TemplateEditor({ experimentId, onClose }) {
|
// ── Main TemplateEditor ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function TemplateEditor({ experimentId, onClose, loadFn, saveFn }) {
|
||||||
const [fields, setFields] = useState([]);
|
const [fields, setFields] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
// Add-field form
|
||||||
const [newLabel, setNewLabel] = useState('');
|
const [newLabel, setNewLabel] = useState('');
|
||||||
const [newType, setNewType] = useState('text');
|
const [newType, setNewType] = useState('text');
|
||||||
const [addError, setAddError] = useState('');
|
const [addError, setAddError] = useState('');
|
||||||
|
|
||||||
|
// Drag state
|
||||||
|
const dragIndex = useRef(null);
|
||||||
|
const [dragOverIndex, setDragOverIndex] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
experimentsApi.getTemplate(experimentId)
|
const fn = loadFn ?? (() => experimentsApi.getTemplate(experimentId));
|
||||||
.then(setFields)
|
fn().then(setFields).catch((e) => setError(e.message)).finally(() => setLoading(false));
|
||||||
.catch((e) => setError(e.message))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [experimentId]);
|
}, [experimentId]);
|
||||||
|
|
||||||
|
// ── field mutation helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
function updateField(updated) {
|
function updateField(updated) {
|
||||||
setFields((prev) => prev.map((f) => f.key === updated.key ? updated : f));
|
setFields((prev) => prev.map((f) => f.fieldId === updated.fieldId ? updated : f));
|
||||||
|
}
|
||||||
|
function toggleField(fieldId) {
|
||||||
|
setFields((prev) => prev.map((f) => f.fieldId === fieldId ? { ...f, active: !f.active } : f));
|
||||||
|
}
|
||||||
|
function removeField(fieldId) {
|
||||||
|
setFields((prev) => prev.filter((f) => f.fieldId !== fieldId));
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleField(key) {
|
// ── tag helpers ────────────────────────────────────────────────────────────
|
||||||
setFields((prev) => prev.map((f) => f.key === key ? { ...f, active: !f.active } : f));
|
|
||||||
|
function addTag(fieldId, value) {
|
||||||
|
setFields((prev) => prev.map((f) =>
|
||||||
|
f.fieldId === fieldId
|
||||||
|
? { ...f, tags: [...(f.tags ?? []), value] }
|
||||||
|
: f
|
||||||
|
));
|
||||||
|
}
|
||||||
|
function removeTag(fieldId, value) {
|
||||||
|
setFields((prev) => prev.map((f) =>
|
||||||
|
f.fieldId === fieldId
|
||||||
|
? { ...f, tags: (f.tags ?? []).filter((t) => t !== value) }
|
||||||
|
: f
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeField(key) {
|
// ── drag handlers ──────────────────────────────────────────────────────────
|
||||||
setFields((prev) => prev.filter((f) => f.key !== key));
|
|
||||||
|
function handleDragStart(index) {
|
||||||
|
dragIndex.current = index;
|
||||||
}
|
}
|
||||||
|
function handleDragEnter(index) {
|
||||||
|
setDragOverIndex(index);
|
||||||
|
}
|
||||||
|
function handleDragEnd() {
|
||||||
|
const from = dragIndex.current;
|
||||||
|
const to = dragOverIndex;
|
||||||
|
if (from !== null && to !== null && from !== to) {
|
||||||
|
setFields((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
const [moved] = next.splice(from, 1);
|
||||||
|
next.splice(to, 0, moved);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
dragIndex.current = null;
|
||||||
|
setDragOverIndex(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── add new field ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function addField() {
|
function addField() {
|
||||||
const trimmed = newLabel.trim();
|
const trimmed = newLabel.trim();
|
||||||
@@ -141,18 +364,21 @@ export default function TemplateEditor({ experimentId, onClose }) {
|
|||||||
if (fields.some((f) => f.key === key)) { setAddError(`A field with key "${key}" already exists`); return; }
|
if (fields.some((f) => f.key === key)) { setAddError(`A field with key "${key}" already exists`); return; }
|
||||||
setAddError('');
|
setAddError('');
|
||||||
const fieldId = crypto.randomUUID();
|
const fieldId = crypto.randomUUID();
|
||||||
setFields((prev) => [...prev, { fieldId, key, label: trimmed, type: newType, builtin: false, active: true }]);
|
setFields((prev) => [...prev, { fieldId, key, label: trimmed, type: newType, builtin: false, active: true, tags: [] }]);
|
||||||
setNewLabel('');
|
setNewLabel('');
|
||||||
setNewType('text');
|
setNewType('text');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── save ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await experimentsApi.updateTemplate(experimentId, fields);
|
const fn = saveFn ?? ((f) => experimentsApi.updateTemplate(experimentId, f));
|
||||||
|
await fn(fields);
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
setTimeout(() => { setSuccess(false); onClose(fields); }, 800);
|
setTimeout(() => { setSuccess(false); onClose(fields); }, 700);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message);
|
setError(e.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -175,14 +401,22 @@ export default function TemplateEditor({ experimentId, onClose }) {
|
|||||||
{fields.length === 0 && (
|
{fields.length === 0 && (
|
||||||
<p className="text-sm text-gray-400 italic text-center py-4">No fields yet. Add one below.</p>
|
<p className="text-sm text-gray-400 italic text-center py-4">No fields yet. Add one below.</p>
|
||||||
)}
|
)}
|
||||||
{fields.map((field) => (
|
{fields.map((field, index) => (
|
||||||
<FieldRow
|
<FieldRow
|
||||||
key={field.key}
|
key={field.fieldId}
|
||||||
field={field}
|
field={field}
|
||||||
|
index={index}
|
||||||
|
total={fields.length}
|
||||||
onChange={updateField}
|
onChange={updateField}
|
||||||
onToggle={toggleField}
|
onToggle={toggleField}
|
||||||
onRemove={removeField}
|
onRemove={removeField}
|
||||||
|
onAddTag={addTag}
|
||||||
|
onRemoveTag={removeTag}
|
||||||
allKeys={allKeys}
|
allKeys={allKeys}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
isDragOver={dragOverIndex === index}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -211,14 +445,12 @@ export default function TemplateEditor({ experimentId, onClose }) {
|
|||||||
<option value="text">Short text</option>
|
<option value="text">Short text</option>
|
||||||
<option value="textarea">Long text</option>
|
<option value="textarea">Long text</option>
|
||||||
</select>
|
</select>
|
||||||
<Button size="sm" onClick={addField} type="button">+ Add</Button>
|
<Button size="sm" type="button" onClick={addField}>+ Add</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info note */}
|
|
||||||
<p className="text-xs text-gray-400">
|
<p className="text-xs text-gray-400">
|
||||||
<strong>Hide</strong> removes a builtin field from forms while preserving its data.
|
Drag <span className="font-mono">⠿</span> to reorder · <strong>Hide</strong> removes a builtin field from forms while preserving its data · <strong>🏷</strong> manages quick-fill tags
|
||||||
<strong className="ml-1">✕</strong> permanently removes a custom field (data in existing records is not deleted).
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
|
|||||||
@@ -1,16 +1,37 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import Modal from './Modal';
|
import Modal from './Modal';
|
||||||
import Button from './Button';
|
import Button from './Button';
|
||||||
|
|
||||||
export default function ConfirmDialog({ isOpen, onClose, onConfirm, title, message, loading }) {
|
export default function ConfirmDialog({ isOpen, onClose, onConfirm, title, message, loading, confirmWord }) {
|
||||||
|
const [typed, setTyped] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => { if (!isOpen) setTyped(''); }, [isOpen]);
|
||||||
|
|
||||||
|
const ready = confirmWord ? typed === confirmWord : true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
|
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
|
||||||
<p className="text-sm text-gray-600 mb-6">{message}</p>
|
<p className="text-sm text-gray-600 mb-4">{message}</p>
|
||||||
|
{confirmWord && (
|
||||||
|
<div className="mb-5">
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">
|
||||||
|
Type <span className="font-mono font-semibold text-gray-700">{confirmWord}</span> to confirm
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={typed}
|
||||||
|
onChange={(e) => setTyped(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
className="w-full border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-red-300 focus:border-red-400"
|
||||||
|
placeholder={confirmWord}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
<Button variant="secondary" onClick={onClose} disabled={loading}>
|
<Button variant="secondary" onClick={onClose} disabled={loading}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="danger" onClick={onConfirm} loading={loading}>
|
<Button variant="danger" onClick={onConfirm} loading={loading} disabled={!ready}>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default function Modal({ isOpen, onClose, title, children, size = 'md' })
|
|||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const widths = { sm: 'max-w-md', md: 'max-w-lg', lg: 'max-w-2xl', xl: 'max-w-4xl' };
|
const widths = { sm: 'max-w-md', md: 'max-w-lg', lg: 'max-w-2xl', xl: 'max-w-4xl', full: 'w-[75vw] max-w-none' };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* CSV Analysis Library
|
||||||
|
*
|
||||||
|
* Method summary:
|
||||||
|
*
|
||||||
|
* 1. PARSING
|
||||||
|
* - parseCSVHeaders(text): reads only the first line, splits on comma
|
||||||
|
* (handles double-quoted fields with embedded commas).
|
||||||
|
* - parseCSV(text): full parse into array-of-objects. Each row is
|
||||||
|
* { [headerName]: value, ... }. Empty trailing fields are preserved as ''.
|
||||||
|
*
|
||||||
|
* 2. UNIQUE VALUES
|
||||||
|
* - getUniqueNoteValues(rows, noteCol): scans each row's noteCol,
|
||||||
|
* trims whitespace, ignores empty strings, returns sorted unique values.
|
||||||
|
*
|
||||||
|
* 3. ANALYSIS (runAnalysis)
|
||||||
|
* Input:
|
||||||
|
* rows — full parsed rows
|
||||||
|
* timestampCol — column name to sort by (numeric sort; falls back to row order)
|
||||||
|
* noteCol — column containing note values
|
||||||
|
* classifications — plain object: { noteValue: categoryName }
|
||||||
|
* unclassified notes are included in the sequence
|
||||||
|
* but excluded from category counts and run stats.
|
||||||
|
*
|
||||||
|
* Steps:
|
||||||
|
* a) Filter to rows where noteCol is non-empty.
|
||||||
|
* b) Sort by timestampCol (parseFloat; NaN values go to the end).
|
||||||
|
* c) Map each row to { note, category, timestamp }.
|
||||||
|
* d) CATEGORY COUNTS: for each category, count total occurrences
|
||||||
|
* and per-note-value breakdown.
|
||||||
|
* e) RUN-LENGTH ENCODING (consecutive stat):
|
||||||
|
* Walk the category sequence. Each time the category changes,
|
||||||
|
* start a new run. Uncategorised notes are skipped (do not break
|
||||||
|
* or continue a run). This gives an ordered list of
|
||||||
|
* { category, length } objects representing every streak.
|
||||||
|
* f) CONSECUTIVE STATS per category:
|
||||||
|
* From the runs list, group run lengths per category and compute
|
||||||
|
* totalRuns, maxRun, avgRun, and the full runLengths array.
|
||||||
|
*
|
||||||
|
* Output: { totalNoteRows, sequence, categoryCounts, runs, consecutiveStats }
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── CSV parsing ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function splitCSVLine(line) {
|
||||||
|
const fields = [];
|
||||||
|
let field = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const ch = line[i];
|
||||||
|
if (ch === '"') {
|
||||||
|
if (inQuotes && line[i + 1] === '"') { field += '"'; i++; }
|
||||||
|
else { inQuotes = !inQuotes; }
|
||||||
|
} else if (ch === ',' && !inQuotes) {
|
||||||
|
fields.push(field);
|
||||||
|
field = '';
|
||||||
|
} else {
|
||||||
|
field += ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fields.push(field);
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return column names from the first line of a CSV string. */
|
||||||
|
export function parseCSVHeaders(text) {
|
||||||
|
const firstLine = text.split(/\r?\n/)[0];
|
||||||
|
return splitCSVLine(firstLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the full CSV into { headers, rows }. */
|
||||||
|
export function parseCSV(text) {
|
||||||
|
const lines = text.split(/\r?\n/);
|
||||||
|
const headers = splitCSVLine(lines[0]);
|
||||||
|
const rows = [];
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
const values = splitCSVLine(line);
|
||||||
|
const row = {};
|
||||||
|
for (let j = 0; j < headers.length; j++) {
|
||||||
|
row[headers[j]] = values[j] ?? '';
|
||||||
|
}
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
return { headers, rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Unique note values ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Return sorted unique non-empty values found in noteCol. */
|
||||||
|
export function getUniqueNoteValues(rows, noteCol) {
|
||||||
|
const seen = new Set();
|
||||||
|
for (const row of rows) {
|
||||||
|
const v = (row[noteCol] ?? '').trim();
|
||||||
|
if (v) seen.add(v);
|
||||||
|
}
|
||||||
|
return [...seen].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Frame gap detection ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a numeric column is strictly consecutive (no gaps, no duplicates).
|
||||||
|
* Returns { consecutive: bool, gaps: [{index, expected, actual, jump}], duplicates: [{index, value}] }
|
||||||
|
* Only examines rows where the column value parses as an integer.
|
||||||
|
*/
|
||||||
|
export function checkFrameConsecutive(rows, frameCol) {
|
||||||
|
const entries = [];
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
const v = rows[i][frameCol];
|
||||||
|
if (v == null || String(v).trim() === '') continue;
|
||||||
|
const n = parseInt(v, 10);
|
||||||
|
if (isNaN(n)) continue;
|
||||||
|
entries.push({ rowIndex: i, value: n });
|
||||||
|
}
|
||||||
|
|
||||||
|
const gaps = [];
|
||||||
|
const duplicates = [];
|
||||||
|
for (let i = 1; i < entries.length; i++) {
|
||||||
|
const diff = entries[i].value - entries[i - 1].value;
|
||||||
|
if (diff === 0) {
|
||||||
|
duplicates.push({ rowIndex: entries[i].rowIndex, value: entries[i].value });
|
||||||
|
} else if (diff !== 1) {
|
||||||
|
gaps.push({
|
||||||
|
rowIndex: entries[i].rowIndex,
|
||||||
|
expected: entries[i - 1].value + 1,
|
||||||
|
actual: entries[i].value,
|
||||||
|
jump: diff,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { consecutive: gaps.length === 0 && duplicates.length === 0, gaps, duplicates, total: entries.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Analysis ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the full analysis.
|
||||||
|
*
|
||||||
|
* @param {object[]} rows - Parsed CSV rows
|
||||||
|
* @param {string} timestampCol - Column to sort by
|
||||||
|
* @param {string} noteCol - Column with note values
|
||||||
|
* @param {object} classifications - { noteValue: categoryName }
|
||||||
|
* @returns {object} Analysis result
|
||||||
|
*/
|
||||||
|
export function runAnalysis(rows, timestampCol, noteCol, classifications) {
|
||||||
|
// Compute session end from ALL rows (not just note rows)
|
||||||
|
let sessionEndTs = null;
|
||||||
|
for (const r of rows) {
|
||||||
|
const t = parseFloat(r[timestampCol]);
|
||||||
|
if (!isNaN(t) && (sessionEndTs === null || t > sessionEndTs)) sessionEndTs = t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// a) Filter non-empty notes
|
||||||
|
const noteRows = rows.filter(r => (r[noteCol] ?? '').trim() !== '');
|
||||||
|
|
||||||
|
// b) Sort by timestamp (numeric); preserve original order for equal/NaN timestamps
|
||||||
|
noteRows.sort((a, b) => {
|
||||||
|
const ta = parseFloat(a[timestampCol]);
|
||||||
|
const tb = parseFloat(b[timestampCol]);
|
||||||
|
if (isNaN(ta) && isNaN(tb)) return 0;
|
||||||
|
if (isNaN(ta)) return 1;
|
||||||
|
if (isNaN(tb)) return -1;
|
||||||
|
return ta - tb;
|
||||||
|
});
|
||||||
|
|
||||||
|
// c) Build sequence
|
||||||
|
const sequence = noteRows.map(r => ({
|
||||||
|
note: r[noteCol].trim(),
|
||||||
|
category: classifications[r[noteCol].trim()] ?? null,
|
||||||
|
timestamp: r[timestampCol],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// d) Category counts
|
||||||
|
const categoryCounts = {};
|
||||||
|
for (const { note, category } of sequence) {
|
||||||
|
if (!category) continue;
|
||||||
|
if (!categoryCounts[category]) categoryCounts[category] = { total: 0, byNote: {} };
|
||||||
|
categoryCounts[category].total++;
|
||||||
|
categoryCounts[category].byNote[note] = (categoryCounts[category].byNote[note] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// e) Run-length encoding (skip uncategorised entries — they neither break nor extend a run)
|
||||||
|
const runs = [];
|
||||||
|
for (const { category } of sequence) {
|
||||||
|
if (!category) continue;
|
||||||
|
if (runs.length === 0 || runs[runs.length - 1].category !== category) {
|
||||||
|
runs.push({ category, length: 1 });
|
||||||
|
} else {
|
||||||
|
runs[runs.length - 1].length++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// f) Consecutive stats per category
|
||||||
|
const consecutiveStats = {};
|
||||||
|
for (const { category, length } of runs) {
|
||||||
|
if (!consecutiveStats[category]) {
|
||||||
|
consecutiveStats[category] = { runLengths: [], totalRuns: 0, maxRun: 0, avgRun: 0 };
|
||||||
|
}
|
||||||
|
const s = consecutiveStats[category];
|
||||||
|
s.runLengths.push(length);
|
||||||
|
s.totalRuns++;
|
||||||
|
if (length > s.maxRun) s.maxRun = length;
|
||||||
|
}
|
||||||
|
for (const cat of Object.keys(consecutiveStats)) {
|
||||||
|
const s = consecutiveStats[cat];
|
||||||
|
s.avgRun = s.runLengths.reduce((a, b) => a + b, 0) / s.runLengths.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalNoteRows: noteRows.length,
|
||||||
|
sessionEndTs,
|
||||||
|
sequence,
|
||||||
|
categoryCounts,
|
||||||
|
runs,
|
||||||
|
consecutiveStats,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
import { useParams, useNavigate, Link, useLocation } from 'react-router-dom';
|
import { useParams, useNavigate, Link, useLocation } from 'react-router-dom';
|
||||||
import { animalsApi, dailyStatusesApi, experimentsApi } from '../api/client';
|
import { animalsApi, dailyStatusesApi, experimentsApi } from '../api/client';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
@@ -6,8 +6,10 @@ import Button from '../components/ui/Button';
|
|||||||
import Modal from '../components/ui/Modal';
|
import Modal from '../components/ui/Modal';
|
||||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||||
import DailyStatusForm from '../components/DailyStatusForm';
|
import DailyStatusForm from '../components/DailyStatusForm';
|
||||||
|
import SubjectInfoForm from '../components/SubjectInfoForm';
|
||||||
import Alert from '../components/ui/Alert';
|
import Alert from '../components/ui/Alert';
|
||||||
import AuditLogSection from '../components/AuditLogSection';
|
import AuditLogSection from '../components/AuditLogSection';
|
||||||
|
import { SubjectAnalysisCharts } from '../components/AnalysisCharts';
|
||||||
|
|
||||||
/** Read the value for a template field from a status record.
|
/** Read the value for a template field from a status record.
|
||||||
* Custom fields are keyed by fieldId, not by the display key. */
|
* Custom fields are keyed by fieldId, not by the display key. */
|
||||||
@@ -16,6 +18,92 @@ function getFieldValue(status, field) {
|
|||||||
return status.custom_fields?.[field.fieldId];
|
return status.custom_fields?.[field.fieldId];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Inline cell editor ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function InlineCell({ statusId, field, value, currentStatus, onSaved }) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [draft, setDraft] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState(null);
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
const isTextarea = field.type === 'textarea';
|
||||||
|
|
||||||
|
function startEdit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDraft(value ?? '');
|
||||||
|
setSaveError(null);
|
||||||
|
setEditing(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editing && inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
if (inputRef.current.select) inputRef.current.select();
|
||||||
|
}
|
||||||
|
}, [editing]);
|
||||||
|
|
||||||
|
async function commit() {
|
||||||
|
setEditing(false);
|
||||||
|
const newValue = draft.trim() || null;
|
||||||
|
if (newValue === (value?.trim() || null)) return;
|
||||||
|
setSaving(true);
|
||||||
|
setSaveError(null);
|
||||||
|
try {
|
||||||
|
let body;
|
||||||
|
if (field.builtin) {
|
||||||
|
body = { [field.key]: newValue };
|
||||||
|
} else {
|
||||||
|
body = { custom_fields: { ...currentStatus.custom_fields, [field.fieldId]: newValue } };
|
||||||
|
}
|
||||||
|
const updated = await dailyStatusesApi.update(statusId, body);
|
||||||
|
onSaved(updated);
|
||||||
|
} catch (err) {
|
||||||
|
setSaveError(err.message ?? 'Save failed');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() { setEditing(false); }
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
const sharedProps = {
|
||||||
|
ref: inputRef,
|
||||||
|
value: draft,
|
||||||
|
onChange: (e) => setDraft(e.target.value),
|
||||||
|
onBlur: commit,
|
||||||
|
onClick: (e) => e.stopPropagation(),
|
||||||
|
onKeyDown: (e) => {
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); cancel(); }
|
||||||
|
if (e.key === 'Enter' && !isTextarea) { e.preventDefault(); commit(); }
|
||||||
|
},
|
||||||
|
className: 'w-full border border-indigo-400 rounded px-1.5 py-0.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-300 bg-white',
|
||||||
|
};
|
||||||
|
return isTextarea
|
||||||
|
? <textarea {...sharedProps} rows={3} className={sharedProps.className + ' resize-none'} />
|
||||||
|
: <input type="text" {...sharedProps} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onDoubleClick={startEdit}
|
||||||
|
title={editing ? '' : 'Double-click to edit'}
|
||||||
|
className={`min-h-[1.25rem] cursor-default rounded transition-colors px-0.5 -mx-0.5
|
||||||
|
${saving ? 'opacity-40' : 'hover:bg-indigo-50/60 group/ic'}`}
|
||||||
|
>
|
||||||
|
{saveError
|
||||||
|
? <span className="text-red-500 text-xs">{saveError}</span>
|
||||||
|
: value
|
||||||
|
? isTextarea
|
||||||
|
? <span className="line-clamp-2 whitespace-pre-wrap">{value}</span>
|
||||||
|
: <span className="truncate block">{value}</span>
|
||||||
|
: <span className="text-gray-200 group-hover/ic:text-gray-400 select-none text-xs">· · ·</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function AnimalDetail() {
|
export default function AnimalDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -23,7 +111,9 @@ export default function AnimalDetail() {
|
|||||||
|
|
||||||
const [animal, setAnimal] = useState(null);
|
const [animal, setAnimal] = useState(null);
|
||||||
const [statuses, setStatuses] = useState([]);
|
const [statuses, setStatuses] = useState([]);
|
||||||
const [template, setTemplate] = useState(location.state?.template ?? []);
|
const [template, setTemplate] = useState([]);
|
||||||
|
const [subjectTemplate, setSubjectTemplate] = useState([]);
|
||||||
|
const [showSubjectInfo, setShowSubjectInfo] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [successMsg, setSuccessMsg] = useState(null);
|
const [successMsg, setSuccessMsg] = useState(null);
|
||||||
@@ -32,6 +122,40 @@ export default function AnimalDetail() {
|
|||||||
const [editStatus, setEditStatus] = useState(null);
|
const [editStatus, setEditStatus] = useState(null);
|
||||||
const [deleteStatus, setDeleteStatus] = useState(null);
|
const [deleteStatus, setDeleteStatus] = useState(null);
|
||||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||||
|
const [showDeleteSubject, setShowDeleteSubject] = useState(false);
|
||||||
|
const [deleteSubjectLoading, setDeleteSubjectLoading] = useState(false);
|
||||||
|
const [fillingGaps, setFillingGaps] = useState(false);
|
||||||
|
|
||||||
|
// Column visibility — persisted per experiment
|
||||||
|
const [hiddenCols, setHiddenCols] = useState(new Set());
|
||||||
|
const [showColPicker, setShowColPicker] = useState(false);
|
||||||
|
const colPickerRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!animal) return;
|
||||||
|
const stored = localStorage.getItem(`col-vis-${animal.id}`);
|
||||||
|
setHiddenCols(stored ? new Set(JSON.parse(stored)) : new Set());
|
||||||
|
}, [animal?.id]);
|
||||||
|
|
||||||
|
function toggleCol(fieldId) {
|
||||||
|
setHiddenCols((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.has(fieldId) ? next.delete(fieldId) : next.add(fieldId);
|
||||||
|
localStorage.setItem(`col-vis-${animal.id}`, JSON.stringify([...next]));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showColPicker) return;
|
||||||
|
function handleClick(e) {
|
||||||
|
if (colPickerRef.current && !colPickerRef.current.contains(e.target)) {
|
||||||
|
setShowColPicker(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClick);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClick);
|
||||||
|
}, [showColPicker]);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -44,11 +168,12 @@ export default function AnimalDetail() {
|
|||||||
setAnimal(animalData);
|
setAnimal(animalData);
|
||||||
setStatuses(statusData);
|
setStatuses(statusData);
|
||||||
|
|
||||||
// Fetch template if not passed via navigation state
|
const [tmpl, subjTmpl] = await Promise.all([
|
||||||
if (!location.state?.template) {
|
experimentsApi.getTemplate(animalData.experiment_id),
|
||||||
const tmpl = await experimentsApi.getTemplate(animalData.experiment_id);
|
experimentsApi.getSubjectTemplate(animalData.experiment_id),
|
||||||
|
]);
|
||||||
setTemplate(tmpl);
|
setTemplate(tmpl);
|
||||||
}
|
setSubjectTemplate(subjTmpl);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -75,6 +200,7 @@ export default function AnimalDetail() {
|
|||||||
try {
|
try {
|
||||||
await dailyStatusesApi.delete(deleteStatus.id);
|
await dailyStatusesApi.delete(deleteStatus.id);
|
||||||
setDeleteStatus(null);
|
setDeleteStatus(null);
|
||||||
|
setEditStatus(null);
|
||||||
load();
|
load();
|
||||||
flash('Daily status removed.');
|
flash('Daily status removed.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -85,6 +211,32 @@ export default function AnimalDetail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fillGaps() {
|
||||||
|
setFillingGaps(true);
|
||||||
|
try {
|
||||||
|
await Promise.all(missingDates.map((date) => dailyStatusesApi.create({ date, animal_id: id })));
|
||||||
|
load();
|
||||||
|
flash(`Created ${missingDates.length} missing entr${missingDates.length === 1 ? 'y' : 'ies'}.`);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setFillingGaps(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteSubject() {
|
||||||
|
setDeleteSubjectLoading(true);
|
||||||
|
try {
|
||||||
|
await animalsApi.delete(animal.id);
|
||||||
|
navigate(`/experiments/${animal.experiment_id}`, { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
setShowDeleteSubject(false);
|
||||||
|
} finally {
|
||||||
|
setDeleteSubjectLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) return <div className="max-w-5xl mx-auto px-4 py-8 text-gray-400" aria-live="polite" aria-busy="true">Loading…</div>;
|
if (loading) return <div className="max-w-5xl mx-auto px-4 py-8 text-gray-400" aria-live="polite" aria-busy="true">Loading…</div>;
|
||||||
if (error) return (
|
if (error) return (
|
||||||
<div className="max-w-5xl mx-auto px-4 py-8">
|
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||||
@@ -94,6 +246,23 @@ export default function AnimalDetail() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const activeFields = template.filter((f) => f.active);
|
const activeFields = template.filter((f) => f.active);
|
||||||
|
const visibleFields = activeFields.filter((f) => !hiddenCols.has(f.fieldId));
|
||||||
|
|
||||||
|
// Dates with entries
|
||||||
|
const existingDateSet = new Set(statuses.map((s) => String(s.date).slice(0, 10)));
|
||||||
|
const missingDates = (() => {
|
||||||
|
if (statuses.length < 2) return [];
|
||||||
|
const sorted = [...existingDateSet].sort();
|
||||||
|
const missing = [];
|
||||||
|
const cur = new Date(sorted[0] + 'T12:00:00');
|
||||||
|
const end = new Date(sorted[sorted.length - 1] + 'T12:00:00');
|
||||||
|
while (cur < end) {
|
||||||
|
cur.setDate(cur.getDate() + 1);
|
||||||
|
const d = cur.toISOString().slice(0, 10);
|
||||||
|
if (!existingDateSet.has(d)) missing.push(d);
|
||||||
|
}
|
||||||
|
return missing;
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-5xl mx-auto px-4 py-8">
|
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||||
@@ -121,85 +290,248 @@ export default function AnimalDetail() {
|
|||||||
{successMsg && <Alert type="success" message={successMsg} />}
|
{successMsg && <Alert type="success" message={successMsg} />}
|
||||||
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
|
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
|
||||||
|
|
||||||
|
{/* Subject information card */}
|
||||||
|
{subjectTemplate.filter((f) => f.active).length > 0 && (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-xl p-5 mb-6">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">Subject Information</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowSubjectInfo(true)}
|
||||||
|
className="text-xs text-gray-400 hover:text-blue-600 transition-colors inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536M9 13l6.586-6.586a2 2 0 112.828 2.828L11.828 15.828A2 2 0 0110 16.414V19h2.586a2 2 0 001.414-.586l6.5-6.5" />
|
||||||
|
</svg>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const filled = subjectTemplate.filter((f) => f.active && animal.subject_info?.[f.fieldId]);
|
||||||
|
if (filled.length === 0) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowSubjectInfo(true)}
|
||||||
|
className="text-sm text-gray-400 italic hover:text-blue-600 transition-colors"
|
||||||
|
>
|
||||||
|
No information recorded yet — click to add
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||||
|
{filled.map((f) => (
|
||||||
|
<div key={f.fieldId} className={f.type === 'textarea' ? 'sm:col-span-2' : ''}>
|
||||||
|
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide">{f.label}</dt>
|
||||||
|
<dd className="text-gray-800 mt-0.5 whitespace-pre-wrap">{animal.subject_info[f.fieldId]}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{statuses.length === 0 ? (
|
{statuses.length === 0 ? (
|
||||||
<div className="text-center py-16 border-2 border-dashed border-gray-200 rounded-xl">
|
<div className="text-center py-16 border-2 border-dashed border-gray-200 rounded-xl">
|
||||||
<p className="text-gray-400 mb-3">No daily statuses logged yet.</p>
|
<p className="text-gray-400 mb-3">No daily statuses logged yet.</p>
|
||||||
<Button onClick={() => setShowAddStatus(true)}>+ Log First Status</Button>
|
<Button onClick={() => setShowAddStatus(true)}>+ Log First Status</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="border border-gray-200 rounded-xl overflow-hidden">
|
||||||
{statuses.map((s) => (
|
{/* Column picker toolbar */}
|
||||||
<div key={s.id} className="bg-white border border-gray-200 rounded-xl p-5">
|
{activeFields.length > 0 && (
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex justify-end px-3 py-1.5 border-b border-gray-100 bg-gray-50/60">
|
||||||
<span className="font-semibold text-gray-900">
|
<div className="relative" ref={colPickerRef}>
|
||||||
{format(new Date(s.date), 'MMMM d, yyyy')}
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowColPicker((v) => !v)}
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-800 px-2 py-1 rounded hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
|
||||||
|
</svg>
|
||||||
|
Columns
|
||||||
|
{hiddenCols.size > 0 && (
|
||||||
|
<span className="ml-0.5 bg-indigo-100 text-indigo-700 rounded-full px-1.5 py-px text-[10px] font-medium leading-none">
|
||||||
|
{activeFields.length - hiddenCols.size}/{activeFields.length}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-2">
|
)}
|
||||||
<Button size="sm" variant="secondary" onClick={() => setEditStatus(s)}>Edit</Button>
|
</button>
|
||||||
<Button size="sm" variant="danger" onClick={() => setDeleteStatus(s)}>Delete</Button>
|
{showColPicker && (
|
||||||
|
<div className="absolute right-0 top-full mt-1 z-30 bg-white border border-gray-200 rounded-lg shadow-lg p-2 min-w-[180px]">
|
||||||
|
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide px-1 mb-1">Show / hide columns</p>
|
||||||
|
{activeFields.map((f) => {
|
||||||
|
const visible = !hiddenCols.has(f.fieldId);
|
||||||
|
return (
|
||||||
|
<label key={f.fieldId} className="flex items-center gap-2 px-1 py-0.5 rounded hover:bg-gray-50 cursor-pointer select-none text-xs text-gray-700">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={visible}
|
||||||
|
onChange={() => toggleCol(f.fieldId)}
|
||||||
|
className="w-3.5 h-3.5 accent-indigo-600"
|
||||||
|
/>
|
||||||
|
<span className="truncate" title={f.label}>{f.label}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{hiddenCols.size > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setHiddenCols(new Set());
|
||||||
|
localStorage.removeItem(`col-vis-${animal.id}`);
|
||||||
|
}}
|
||||||
|
className="mt-1 w-full text-left text-[10px] text-indigo-500 hover:text-indigo-700 px-1 py-0.5"
|
||||||
|
>
|
||||||
|
Show all
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeFields.length > 0 ? (
|
<div className="overflow-x-auto">
|
||||||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
<table className="text-sm border-collapse min-w-full">
|
||||||
{activeFields
|
<thead>
|
||||||
.map((f) => ({ field: f, value: getFieldValue(s, f) }))
|
<tr className="bg-gray-50 border-b border-gray-200">
|
||||||
.filter(({ value }) => value)
|
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide whitespace-nowrap sticky left-0 bg-gray-50 z-10 border-r border-gray-200 min-w-[110px]">
|
||||||
.map(({ field, value }) => (
|
Date
|
||||||
<div key={field.key} className={field.type === 'textarea' ? 'sm:col-span-2' : ''}>
|
</th>
|
||||||
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide">{field.label}</dt>
|
{visibleFields.map((f) => {
|
||||||
<dd className="text-gray-800 mt-0.5 whitespace-pre-wrap">{value}</dd>
|
const header = f.abbr ? (f.unit ? `${f.abbr} (${f.unit})` : f.abbr) : (f.unit ? `${f.label} (${f.unit})` : f.label);
|
||||||
</div>
|
const tooltip = f.unit ? `${f.label} (${f.unit})` : f.label;
|
||||||
))}
|
const minW = Math.max(80, header.length * 7.5 + 24);
|
||||||
</dl>
|
return (
|
||||||
) : null}
|
<th key={f.key} title={tooltip} style={{ minWidth: minW }} className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide whitespace-nowrap max-w-[220px] cursor-default">
|
||||||
|
{header}
|
||||||
{/* Fallback: show raw builtin fields if template is empty */}
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{activeFields.length === 0 && (
|
{activeFields.length === 0 && (
|
||||||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
<>
|
||||||
{[
|
{['Experiment Description', 'Vitals', 'Treatment', 'Notes'].map((l) => (
|
||||||
['Experiment Description', s.experiment_description],
|
<th key={l} className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide whitespace-nowrap min-w-[120px]">{l}</th>
|
||||||
['Vitals', s.vitals],
|
|
||||||
['Treatment', s.treatment],
|
|
||||||
['Notes', s.notes],
|
|
||||||
].filter(([, v]) => v).map(([label, value]) => (
|
|
||||||
<div key={label}>
|
|
||||||
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide">{label}</dt>
|
|
||||||
<dd className="text-gray-800 mt-0.5 whitespace-pre-wrap">{value}</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</dl>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<th className="sticky right-0 bg-gray-50 z-10 border-l border-gray-200 px-3 py-2 min-w-[96px]" />
|
||||||
{activeFields.length > 0 &&
|
</tr>
|
||||||
activeFields.every((f) => !getFieldValue(s, f)) && (
|
</thead>
|
||||||
<p className="text-sm text-gray-400 italic">No details recorded for this date.</p>
|
<tbody>
|
||||||
)}
|
{statuses.map((s, i) => {
|
||||||
</div>
|
const rowBg = i % 2 === 0 ? 'bg-white' : 'bg-gray-50/40';
|
||||||
|
const renderFields = activeFields.length > 0 ? visibleFields : [
|
||||||
|
{ fieldId: '__exp_desc__', key: 'experiment_description', label: 'Experiment Description', type: 'textarea', builtin: true, active: true },
|
||||||
|
{ fieldId: '__vitals__', key: 'vitals', label: 'Vitals', type: 'text', builtin: true, active: true },
|
||||||
|
{ fieldId: '__treatment__',key: 'treatment', label: 'Treatment', type: 'text', builtin: true, active: true },
|
||||||
|
{ fieldId: '__notes__', key: 'notes', label: 'Notes', type: 'textarea', builtin: true, active: true },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<tr key={s.id} className={`border-b border-gray-100 last:border-0 ${rowBg}`}>
|
||||||
|
<td
|
||||||
|
className={`px-3 py-2 font-medium text-gray-700 whitespace-nowrap sticky left-0 z-10 border-r border-gray-100 cursor-pointer hover:text-blue-600 hover:bg-blue-50/40 transition-colors ${rowBg}`}
|
||||||
|
onClick={() => navigate(`/daily-statuses/${s.id}`)}
|
||||||
|
>
|
||||||
|
{format(new Date(String(s.date).slice(0, 10) + 'T12:00:00'), 'MMM d, yyyy')}
|
||||||
|
</td>
|
||||||
|
{renderFields.map((f) => (
|
||||||
|
<td key={f.fieldId} className="px-3 py-1.5 text-gray-700 max-w-[220px]">
|
||||||
|
<InlineCell
|
||||||
|
statusId={s.id}
|
||||||
|
field={f}
|
||||||
|
value={getFieldValue(s, f)}
|
||||||
|
currentStatus={s}
|
||||||
|
onSaved={(updated) => setStatuses((prev) => prev.map((x) => x.id === s.id ? { ...x, ...updated } : x))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
))}
|
))}
|
||||||
|
<td className={`px-2 py-1.5 sticky right-0 z-10 border-l border-gray-100 ${rowBg}`}>
|
||||||
|
<div className="flex gap-1 justify-end">
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => setEditStatus(s)}>Edit</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AuditLogSection tableName="daily_statuses" />
|
{missingDates.length > 0 && (
|
||||||
|
<div className="mt-3 flex items-center justify-between px-4 py-3 bg-amber-50 border border-amber-200 rounded-xl">
|
||||||
|
<p className="text-sm text-amber-800">
|
||||||
|
<span className="font-semibold">{missingDates.length}</span> date{missingDates.length !== 1 ? 's' : ''} between the earliest and latest entries have no record.
|
||||||
|
</p>
|
||||||
|
<Button variant="secondary" size="sm" onClick={fillGaps} loading={fillingGaps}>
|
||||||
|
Create {missingDates.length} empty entr{missingDates.length === 1 ? 'y' : 'ies'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Modal isOpen={showAddStatus} onClose={() => setShowAddStatus(false)} title="Log Daily Status" size="lg">
|
<SubjectAnalysisCharts statuses={statuses} dailyTemplate={template} />
|
||||||
|
|
||||||
|
<AuditLogSection tableName="daily_statuses" limit={5} />
|
||||||
|
|
||||||
|
{/* Danger zone */}
|
||||||
|
<div className="mt-10 border border-red-200 rounded-xl p-4 bg-red-50/40">
|
||||||
|
<h2 className="text-xs font-semibold text-red-600 uppercase tracking-wide mb-2">Danger Zone</h2>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-gray-600">Permanently delete this subject and all its daily status records.</p>
|
||||||
|
<Button variant="danger" onClick={() => setShowDeleteSubject(true)}>Delete Subject</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal isOpen={showSubjectInfo} onClose={() => setShowSubjectInfo(false)} title="Subject Information" size="full">
|
||||||
|
<SubjectInfoForm
|
||||||
|
animal={animal}
|
||||||
|
subjectTemplate={subjectTemplate}
|
||||||
|
experimentId={animal?.experiment_id}
|
||||||
|
onTemplateUpdate={setSubjectTemplate}
|
||||||
|
onSaved={(updated) => { setAnimal(updated); setShowSubjectInfo(false); flash('Subject information saved.'); }}
|
||||||
|
onCancel={() => setShowSubjectInfo(false)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal isOpen={showAddStatus} onClose={() => setShowAddStatus(false)} title="Log Daily Status" size="full">
|
||||||
<DailyStatusForm
|
<DailyStatusForm
|
||||||
animalId={id}
|
animalId={id}
|
||||||
|
experimentId={animal?.experiment_id}
|
||||||
template={template}
|
template={template}
|
||||||
|
onTemplateUpdate={setTemplate}
|
||||||
onSuccess={handleStatusSaved}
|
onSuccess={handleStatusSaved}
|
||||||
onCancel={() => setShowAddStatus(false)}
|
onCancel={() => setShowAddStatus(false)}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal isOpen={!!editStatus} onClose={() => setEditStatus(null)} title="Edit Daily Status" size="lg">
|
{(() => {
|
||||||
|
const editIdx = editStatus ? statuses.findIndex((s) => s.id === editStatus.id) : -1;
|
||||||
|
return (
|
||||||
|
<Modal isOpen={!!editStatus} onClose={() => setEditStatus(null)} title="Edit Daily Status" size="full">
|
||||||
<DailyStatusForm
|
<DailyStatusForm
|
||||||
existing={editStatus}
|
existing={editStatus}
|
||||||
animalId={id}
|
animalId={id}
|
||||||
|
experimentId={animal?.experiment_id}
|
||||||
template={template}
|
template={template}
|
||||||
|
onTemplateUpdate={setTemplate}
|
||||||
onSuccess={handleStatusSaved}
|
onSuccess={handleStatusSaved}
|
||||||
onCancel={() => setEditStatus(null)}
|
onCancel={() => setEditStatus(null)}
|
||||||
|
onRequestDelete={() => setDeleteStatus(editStatus)}
|
||||||
|
hasPrev={editIdx > 0}
|
||||||
|
hasNext={editIdx < statuses.length - 1}
|
||||||
|
onNavigate={(direction, savedResult) => {
|
||||||
|
setStatuses((prev) => prev.map((s) => s.id === savedResult.id ? savedResult : s));
|
||||||
|
setEditStatus(statuses[direction === 'prev' ? editIdx - 1 : editIdx + 1]);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={!!deleteStatus}
|
isOpen={!!deleteStatus}
|
||||||
@@ -207,7 +539,18 @@ export default function AnimalDetail() {
|
|||||||
onConfirm={handleDeleteStatus}
|
onConfirm={handleDeleteStatus}
|
||||||
loading={deleteLoading}
|
loading={deleteLoading}
|
||||||
title="Delete Daily Status"
|
title="Delete Daily Status"
|
||||||
message={`Delete the status entry for ${deleteStatus ? format(new Date(deleteStatus.date), 'MMMM d, yyyy') : ''}?`}
|
message={`Delete the status entry for ${deleteStatus ? format(new Date(String(deleteStatus.date).slice(0, 10) + 'T12:00:00'), 'MMMM d, yyyy') : ''}? This cannot be undone.`}
|
||||||
|
confirmWord="delete"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={showDeleteSubject}
|
||||||
|
onClose={() => setShowDeleteSubject(false)}
|
||||||
|
onConfirm={handleDeleteSubject}
|
||||||
|
loading={deleteSubjectLoading}
|
||||||
|
title="Delete Subject"
|
||||||
|
message={`This will permanently delete "${animal.animal_name}" and all ${statuses.length} daily status record${statuses.length !== 1 ? 's' : ''}. This cannot be undone.`}
|
||||||
|
confirmWord="delete"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,601 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { dailyStatusesApi, experimentsApi, analysesApi } from '../api/client';
|
||||||
|
import Button from '../components/ui/Button';
|
||||||
|
import Modal from '../components/ui/Modal';
|
||||||
|
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||||
|
import DailyStatusForm from '../components/DailyStatusForm';
|
||||||
|
import Alert from '../components/ui/Alert';
|
||||||
|
import CsvAnalysis from '../components/CsvAnalysis';
|
||||||
|
import AuditLogSection from '../components/AuditLogSection';
|
||||||
|
import RunSequenceView from '../components/RunSequenceView';
|
||||||
|
|
||||||
|
function fmtDate(raw) {
|
||||||
|
return format(new Date(String(raw).slice(0, 10) + 'T12:00:00'), 'MMMM d, yyyy');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDateTime(raw) {
|
||||||
|
return format(new Date(raw), 'MMM d, yyyy · h:mm a');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFieldValue(status, field) {
|
||||||
|
if (field.builtin) return status[field.key];
|
||||||
|
return status.custom_fields?.[field.fieldId];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Analysis summary card ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function AnalysisSummaryCard({ summary }) {
|
||||||
|
const { counts = {}, total, success_rate, computed_at } = summary;
|
||||||
|
const COLORS = {
|
||||||
|
Success: 'bg-green-100 text-green-800',
|
||||||
|
Failure: 'bg-red-100 text-red-800',
|
||||||
|
Other: 'bg-gray-100 text-gray-700',
|
||||||
|
};
|
||||||
|
const EXTRA = ['bg-indigo-100 text-indigo-800', 'bg-yellow-100 text-yellow-800', 'bg-purple-100 text-purple-800', 'bg-pink-100 text-pink-800'];
|
||||||
|
const catList = Object.entries(counts).sort(([, a], [, b]) => b - a);
|
||||||
|
let extraIdx = 0;
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-xl px-5 py-4 mb-6">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Session metrics</p>
|
||||||
|
{computed_at && <p className="text-[10px] text-gray-400">Updated {fmtDateTime(computed_at)}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
|
{catList.map(([cat, n]) => {
|
||||||
|
const cls = COLORS[cat] ?? EXTRA[extraIdx++ % EXTRA.length];
|
||||||
|
return (
|
||||||
|
<div key={cat} className={`flex flex-col items-center px-3 py-2 rounded-lg ${cls}`}>
|
||||||
|
<span className="text-xs font-medium">{cat}</span>
|
||||||
|
<span className="text-xl font-bold leading-tight">{n}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="flex flex-col items-center px-3 py-2 rounded-lg bg-blue-50 text-blue-800">
|
||||||
|
<span className="text-xs font-medium">Total</span>
|
||||||
|
<span className="text-xl font-bold leading-tight">{total}</span>
|
||||||
|
</div>
|
||||||
|
{success_rate != null && (
|
||||||
|
<div className="flex flex-col items-center px-3 py-2 rounded-lg bg-indigo-50 text-indigo-800">
|
||||||
|
<span className="text-xs font-medium">Success rate</span>
|
||||||
|
<span className="text-xl font-bold leading-tight">{(success_rate * 100).toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Manual metrics form ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DEFAULT_CATS = ['Success', 'Failure', 'Other'];
|
||||||
|
|
||||||
|
function ManualMetricsForm({ statusId, existing, onSaved, onClose }) {
|
||||||
|
const initCounts = () => {
|
||||||
|
const base = { ...existing?.counts };
|
||||||
|
for (const c of DEFAULT_CATS) if (!(c in base)) base[c] = 0;
|
||||||
|
return base;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [counts, setCounts] = useState(initCounts);
|
||||||
|
const [newCat, setNewCat] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const total = Object.values(counts).reduce((a, b) => a + (Number(b) || 0), 0);
|
||||||
|
const successRate = total > 0 && counts['Success'] != null
|
||||||
|
? (Number(counts['Success']) || 0) / total
|
||||||
|
: null;
|
||||||
|
|
||||||
|
function setCount(cat, raw) {
|
||||||
|
const v = Math.max(0, parseInt(raw, 10) || 0);
|
||||||
|
setCounts((prev) => ({ ...prev, [cat]: v }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCat() {
|
||||||
|
const name = newCat.trim();
|
||||||
|
if (!name || name in counts) return;
|
||||||
|
setCounts((prev) => ({ ...prev, [name]: 0 }));
|
||||||
|
setNewCat('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCat(cat) {
|
||||||
|
if (DEFAULT_CATS.includes(cat)) return;
|
||||||
|
setCounts((prev) => { const n = { ...prev }; delete n[cat]; return n; });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const finalCounts = Object.fromEntries(
|
||||||
|
Object.entries(counts).map(([k, v]) => [k, Number(v) || 0])
|
||||||
|
);
|
||||||
|
await dailyStatusesApi.updateAnalysisSummary(statusId, {
|
||||||
|
counts: finalCounts,
|
||||||
|
total,
|
||||||
|
success_rate: successRate,
|
||||||
|
source_analysis_id: null,
|
||||||
|
});
|
||||||
|
onSaved({ counts: finalCounts, total, success_rate: successRate });
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message ?? 'Failed to save');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-xl px-5 py-4 mb-6">
|
||||||
|
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">Set metrics manually</p>
|
||||||
|
<div className="flex flex-wrap gap-3 items-end mb-3">
|
||||||
|
{Object.entries(counts).map(([cat, val]) => (
|
||||||
|
<div key={cat} className="flex flex-col items-center gap-1">
|
||||||
|
<span className="text-xs text-gray-500">{cat}</span>
|
||||||
|
<input
|
||||||
|
type="number" min={0} value={val}
|
||||||
|
onChange={(e) => setCount(cat, e.target.value)}
|
||||||
|
className="w-20 text-center border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
||||||
|
/>
|
||||||
|
{!DEFAULT_CATS.includes(cat) && (
|
||||||
|
<button type="button" onClick={() => removeCat(cat)}
|
||||||
|
className="text-[10px] text-red-400 hover:text-red-600">remove</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Add custom category inline */}
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<span className="text-xs text-gray-400">+ category</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<input value={newCat} onChange={(e) => setNewCat(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') addCat(); }}
|
||||||
|
placeholder="Name…"
|
||||||
|
className="w-24 border border-dashed border-gray-300 rounded px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-indigo-400" />
|
||||||
|
<button type="button" onClick={addCat}
|
||||||
|
className="text-xs text-indigo-500 hover:text-indigo-700 px-1">Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-xs text-gray-500 mb-3">
|
||||||
|
<span>Total: <strong className="text-gray-800">{total}</strong></span>
|
||||||
|
{successRate != null && (
|
||||||
|
<span>Success rate: <strong className="text-gray-800">{(successRate * 100).toFixed(1)}%</strong></span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-xs text-red-500 mb-2">{error}</p>}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" onClick={save} disabled={saving}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-sm font-medium bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 transition-colors">
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onClose}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-sm text-gray-500 hover:text-gray-700 border border-gray-200 transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Past analysis row (expandable) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
function AnalysisRow({ analysis, onDelete }) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [full, setFull] = useState(null);
|
||||||
|
const [loadingFull, setLoadingFull] = useState(false);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
async function expand() {
|
||||||
|
if (expanded) { setExpanded(false); return; }
|
||||||
|
setExpanded(true);
|
||||||
|
if (full) return;
|
||||||
|
setLoadingFull(true);
|
||||||
|
try {
|
||||||
|
const data = await analysesApi.get(analysis.id);
|
||||||
|
setFull(data);
|
||||||
|
} finally {
|
||||||
|
setLoadingFull(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await analysesApi.delete(analysis.id);
|
||||||
|
onDelete(analysis.id);
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const counts = analysis.category_counts ?? {};
|
||||||
|
const summary = Object.entries(counts)
|
||||||
|
.sort(([, a], [, b]) => b.total - a.total)
|
||||||
|
.map(([cat, d]) => `${cat}: ${d.total}`)
|
||||||
|
.join(' · ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-4 py-3 cursor-pointer hover:bg-gray-50 transition-colors"
|
||||||
|
onClick={expand}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<span className="text-gray-400 text-xs">{expanded ? '▾' : '▸'}</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-800 truncate">
|
||||||
|
{analysis.file_name ?? 'Unnamed file'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
{fmtDateTime(analysis.created_at)}
|
||||||
|
{' · '}
|
||||||
|
<span className="font-mono">{analysis.note_col}</span>
|
||||||
|
{' · '}
|
||||||
|
{analysis.total_note_rows} note rows
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 ml-4 shrink-0">
|
||||||
|
<span className="text-xs text-gray-500 hidden sm:block">{summary}</span>
|
||||||
|
<button type="button" onClick={(e) => { e.stopPropagation(); setConfirmDelete(true); }}
|
||||||
|
className="text-xs text-red-400 hover:text-red-600 transition-colors">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="border-t border-gray-100 px-4 py-4 bg-gray-50/40">
|
||||||
|
{loadingFull && <p className="text-sm text-gray-400">Loading…</p>}
|
||||||
|
{full && <AnalysisResultsView analysis={full} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={confirmDelete}
|
||||||
|
onClose={() => setConfirmDelete(false)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
loading={deleting}
|
||||||
|
title="Delete Analysis"
|
||||||
|
message="Remove this analysis record? The raw CSV is not affected."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Analysis results display (reused by both live and past) ───────────────────
|
||||||
|
|
||||||
|
export function AnalysisResultsView({ analysis }) {
|
||||||
|
const { timestamp_col, note_col, total_note_rows,
|
||||||
|
category_counts, consecutive_stats, runs } = analysis;
|
||||||
|
const customCategories = [];
|
||||||
|
|
||||||
|
function colorsFor(cat) {
|
||||||
|
const COLORS = {
|
||||||
|
Success: { bg: 'bg-green-100', text: 'text-green-800', dot: 'bg-green-500', border: 'border-green-300' },
|
||||||
|
Failure: { bg: 'bg-red-100', text: 'text-red-800', dot: 'bg-red-500', border: 'border-red-300' },
|
||||||
|
Other: { bg: 'bg-gray-100', text: 'text-gray-700', dot: 'bg-gray-400', border: 'border-gray-300' },
|
||||||
|
};
|
||||||
|
const EXTRA = [
|
||||||
|
{ bg: 'bg-indigo-100', text: 'text-indigo-800', dot: 'bg-indigo-500', border: 'border-indigo-300' },
|
||||||
|
{ bg: 'bg-yellow-100', text: 'text-yellow-800', dot: 'bg-yellow-500', border: 'border-yellow-300' },
|
||||||
|
{ bg: 'bg-purple-100', text: 'text-purple-800', dot: 'bg-purple-500', border: 'border-purple-300' },
|
||||||
|
];
|
||||||
|
if (COLORS[cat]) return COLORS[cat];
|
||||||
|
const idx = customCategories.indexOf(cat) % EXTRA.length;
|
||||||
|
return EXTRA[Math.max(0, idx)];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5 text-sm">
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
{total_note_rows} note rows · timestamp: <span className="font-mono">{timestamp_col}</span>
|
||||||
|
{' '}/ note: <span className="font-mono">{note_col}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Category counts */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1.5">Category Counts</p>
|
||||||
|
<table className="w-full border-collapse text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-white border-b border-gray-200">
|
||||||
|
<th className="text-left px-3 py-1.5 text-xs font-semibold text-gray-500">Category</th>
|
||||||
|
<th className="text-left px-3 py-1.5 text-xs font-semibold text-gray-500">Breakdown</th>
|
||||||
|
<th className="text-right px-3 py-1.5 text-xs font-semibold text-gray-500">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{Object.entries(category_counts ?? {})
|
||||||
|
.sort(([, a], [, b]) => b.total - a.total)
|
||||||
|
.map(([cat, data]) => {
|
||||||
|
const c = colorsFor(cat);
|
||||||
|
const bd = Object.entries(data.byNote ?? {})
|
||||||
|
.sort(([, a], [, b]) => b - a)
|
||||||
|
.map(([n, cnt]) => `${n}: ${cnt}`).join(' · ');
|
||||||
|
return (
|
||||||
|
<tr key={cat} className="border-b border-gray-100">
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ${c.bg} ${c.text}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${c.dot}`} />
|
||||||
|
{cat}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-gray-500 font-mono text-xs">{bd}</td>
|
||||||
|
<td className="px-3 py-1.5 text-right font-semibold text-gray-800">{data.total}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Consecutive stats */}
|
||||||
|
{Object.keys(consecutive_stats ?? {}).length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1.5">Consecutive Runs</p>
|
||||||
|
<table className="w-full border-collapse text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-white border-b border-gray-200">
|
||||||
|
<th className="text-left px-3 py-1.5 text-xs font-semibold text-gray-500">Category</th>
|
||||||
|
<th className="text-right px-3 py-1.5 text-xs font-semibold text-gray-500">Runs</th>
|
||||||
|
<th className="text-right px-3 py-1.5 text-xs font-semibold text-gray-500">Max</th>
|
||||||
|
<th className="text-right px-3 py-1.5 text-xs font-semibold text-gray-500">Avg</th>
|
||||||
|
<th className="text-left px-3 py-1.5 text-xs font-semibold text-gray-500">Lengths</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{Object.entries(consecutive_stats).map(([cat, s]) => {
|
||||||
|
const c = colorsFor(cat);
|
||||||
|
const summary = Object.entries(
|
||||||
|
s.runLengths.reduce((acc, l) => { acc[l] = (acc[l] || 0) + 1; return acc; }, {})
|
||||||
|
).sort(([a], [b]) => Number(a) - Number(b))
|
||||||
|
.map(([l, cnt]) => `${l}×${cnt}`).join(', ');
|
||||||
|
return (
|
||||||
|
<tr key={cat} className="border-b border-gray-100">
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ${c.bg} ${c.text}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${c.dot}`} />
|
||||||
|
{cat}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-right text-gray-700">{s.totalRuns}</td>
|
||||||
|
<td className="px-3 py-1.5 text-right text-gray-700">{s.maxRun}</td>
|
||||||
|
<td className="px-3 py-1.5 text-right text-gray-700">{s.avgRun.toFixed(2)}</td>
|
||||||
|
<td className="px-3 py-1.5 text-gray-500 font-mono text-xs">{summary}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<RunSequenceView runs={runs ?? []} sequence={analysis.sequence ?? []} sessionEndTs={analysis.session_end_ts ?? null} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function DailyStatusDetail() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [status, setStatus] = useState(null);
|
||||||
|
const [template, setTemplate] = useState([]);
|
||||||
|
const [allStatuses, setAllStatuses] = useState([]);
|
||||||
|
const [analyses, setAnalyses] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const [showEdit, setShowEdit] = useState(false);
|
||||||
|
const [showDelete, setShowDelete] = useState(false);
|
||||||
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||||
|
const [showManualForm, setShowManualForm] = useState(false);
|
||||||
|
|
||||||
|
async function load(statusId = id) {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const s = await dailyStatusesApi.get(statusId);
|
||||||
|
const [tmpl, siblings, pastAnalyses] = await Promise.all([
|
||||||
|
experimentsApi.getTemplate(s.animal.experiment_id),
|
||||||
|
dailyStatusesApi.list(s.animal_id),
|
||||||
|
analysesApi.listForStatus(statusId),
|
||||||
|
]);
|
||||||
|
setStatus(s);
|
||||||
|
setTemplate(tmpl);
|
||||||
|
setAllStatuses(siblings);
|
||||||
|
setAnalyses(pastAnalyses);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [id]);
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
setDeleteLoading(true);
|
||||||
|
try {
|
||||||
|
await dailyStatusesApi.delete(status.id);
|
||||||
|
navigate(`/animals/${status.animal_id}`, { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
setShowDelete(false);
|
||||||
|
} finally {
|
||||||
|
setDeleteLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="max-w-3xl mx-auto px-4 py-8 text-gray-400">Loading…</div>;
|
||||||
|
if (error) return (
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-8">
|
||||||
|
<Alert type="error" message={error} />
|
||||||
|
<Button variant="secondary" className="mt-4" onClick={() => navigate(-1)}>← Back</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const animal = status.animal;
|
||||||
|
const activeFields = template.filter((f) => f.active);
|
||||||
|
|
||||||
|
const currentIdx = allStatuses.findIndex((s) => s.id === status.id);
|
||||||
|
const prevStatus = currentIdx > 0 ? allStatuses[currentIdx - 1] : null;
|
||||||
|
const nextStatus = currentIdx < allStatuses.length - 1 ? allStatuses[currentIdx + 1] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-8">
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<nav className="text-sm text-gray-500 mb-4">
|
||||||
|
<Link to="/" className="hover:text-blue-600">Experiments</Link>
|
||||||
|
<span className="mx-2">/</span>
|
||||||
|
<Link to={`/experiments/${animal.experiment_id}`} className="hover:text-blue-600">Experiment</Link>
|
||||||
|
<span className="mx-2">/</span>
|
||||||
|
<Link to={`/animals/${animal.id}`} className="hover:text-blue-600">{animal.animal_name}</Link>
|
||||||
|
<span className="mx-2">/</span>
|
||||||
|
<span className="text-gray-900 font-medium">{fmtDate(status.date)}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{fmtDate(status.date)}</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">
|
||||||
|
{animal.animal_name}
|
||||||
|
{animal.animal_id_string && <> · <span className="font-mono">{animal.animal_id_string}</span></>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="secondary" onClick={() => setShowEdit(true)}>Edit entry</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
|
||||||
|
|
||||||
|
{/* Field values */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-xl p-6 mb-6">
|
||||||
|
{activeFields.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400 italic">No template fields configured.</p>
|
||||||
|
) : (
|
||||||
|
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-4">
|
||||||
|
{activeFields.map((f) => {
|
||||||
|
const value = getFieldValue(status, f);
|
||||||
|
const label = f.unit ? `${f.label} (${f.unit})` : f.label;
|
||||||
|
return (
|
||||||
|
<div key={f.fieldId} className={f.type === 'textarea' ? 'sm:col-span-2' : ''}>
|
||||||
|
<dt className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-0.5">{label}</dt>
|
||||||
|
<dd className="text-gray-800 whitespace-pre-wrap break-words">
|
||||||
|
{value ?? <span className="text-gray-300 italic">—</span>}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Analysis summary (pinned metrics) */}
|
||||||
|
{status.analysis_summary && !showManualForm && (
|
||||||
|
<AnalysisSummaryCard summary={status.analysis_summary} />
|
||||||
|
)}
|
||||||
|
{showManualForm ? (
|
||||||
|
<ManualMetricsForm
|
||||||
|
statusId={status.id}
|
||||||
|
existing={status.analysis_summary}
|
||||||
|
onSaved={(summary) => {
|
||||||
|
setStatus((prev) => ({ ...prev, analysis_summary: { ...summary, computed_at: new Date().toISOString() } }));
|
||||||
|
setShowManualForm(false);
|
||||||
|
}}
|
||||||
|
onClose={() => setShowManualForm(false)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="mb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowManualForm(true)}
|
||||||
|
className="text-xs text-gray-400 hover:text-indigo-600 border border-dashed border-gray-200 hover:border-indigo-300 rounded-lg px-3 py-1.5 transition-colors"
|
||||||
|
>
|
||||||
|
{status.analysis_summary ? 'Edit metrics manually' : 'Set metrics manually'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* CSV Analysis — new analysis */}
|
||||||
|
<CsvAnalysis
|
||||||
|
dailyStatusId={status.id}
|
||||||
|
onSaved={(saved) => setAnalyses((prev) => [saved, ...prev])}
|
||||||
|
onSummaryPushed={(summary) => setStatus((prev) => ({ ...prev, analysis_summary: { ...summary, computed_at: new Date().toISOString() } }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Past analyses */}
|
||||||
|
{analyses.length > 0 && (
|
||||||
|
<div className="mt-8 border-t border-gray-200 pt-6">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">
|
||||||
|
Past Analyses ({analyses.length})
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{analyses.map((a) => (
|
||||||
|
<AnalysisRow
|
||||||
|
key={a.id}
|
||||||
|
analysis={a}
|
||||||
|
onDelete={(deletedId) => setAnalyses((prev) => prev.filter((x) => x.id !== deletedId))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Full modification history for this entry */}
|
||||||
|
<AuditLogSection tableName="daily_statuses" recordId={status.id} />
|
||||||
|
|
||||||
|
{/* Prev / Next navigation */}
|
||||||
|
<div className="flex justify-between items-center mt-8 mb-6">
|
||||||
|
<div>
|
||||||
|
{prevStatus && (
|
||||||
|
<button type="button" onClick={() => navigate(`/daily-statuses/${prevStatus.id}`)}
|
||||||
|
className="text-sm text-blue-600 hover:text-blue-800 hover:underline">
|
||||||
|
← {fmtDate(prevStatus.date)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Link to={`/animals/${animal.id}`} className="text-sm text-gray-500 hover:text-gray-800">
|
||||||
|
All entries
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
{nextStatus && (
|
||||||
|
<button type="button" onClick={() => navigate(`/daily-statuses/${nextStatus.id}`)}
|
||||||
|
className="text-sm text-blue-600 hover:text-blue-800 hover:underline">
|
||||||
|
{fmtDate(nextStatus.date)} →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit modal */}
|
||||||
|
<Modal isOpen={showEdit} onClose={() => setShowEdit(false)} title="Edit Daily Status" size="full">
|
||||||
|
<DailyStatusForm
|
||||||
|
existing={status}
|
||||||
|
animalId={animal.id}
|
||||||
|
experimentId={animal.experiment_id}
|
||||||
|
template={template}
|
||||||
|
onTemplateUpdate={setTemplate}
|
||||||
|
onSuccess={(updated) => { setStatus({ ...updated, animal }); setShowEdit(false); }}
|
||||||
|
onCancel={() => setShowEdit(false)}
|
||||||
|
onRequestDelete={() => { setShowEdit(false); setShowDelete(true); }}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={showDelete}
|
||||||
|
onClose={() => setShowDelete(false)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
loading={deleteLoading}
|
||||||
|
title="Delete Daily Status"
|
||||||
|
message={`Delete the entry for ${fmtDate(status.date)}? This cannot be undone.`}
|
||||||
|
confirmWord="delete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,52 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState, useMemo } from 'react';
|
||||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
import { experimentsApi, animalsApi } from '../api/client';
|
import { experimentsApi, animalsApi } from '../api/client';
|
||||||
|
import { ExperimentAnalysisCharts } from '../components/AnalysisCharts';
|
||||||
import Button from '../components/ui/Button';
|
import Button from '../components/ui/Button';
|
||||||
import Modal from '../components/ui/Modal';
|
import Modal from '../components/ui/Modal';
|
||||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
|
||||||
import AnimalForm from '../components/AnimalForm';
|
import AnimalForm from '../components/AnimalForm';
|
||||||
import TemplateEditor from '../components/TemplateEditor';
|
import TemplateEditor from '../components/TemplateEditor';
|
||||||
import Alert from '../components/ui/Alert';
|
import Alert from '../components/ui/Alert';
|
||||||
import AuditLogSection from '../components/AuditLogSection';
|
import AuditLogSection from '../components/AuditLogSection';
|
||||||
|
import ExperimentCalendar from '../components/ExperimentCalendar';
|
||||||
|
|
||||||
|
function AnimalCardList({ animals, subjectTemplate, onNavigate, onEdit }) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{animals.map((animal) => {
|
||||||
|
const infoFields = subjectTemplate.filter((f) => f.active && animal.subject_info?.[f.fieldId] != null);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={animal.id}
|
||||||
|
className="bg-white border border-gray-200 rounded-xl p-4 flex items-center justify-between hover:border-blue-300 hover:shadow-sm transition-all cursor-pointer group"
|
||||||
|
onClick={() => onNavigate(animal)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-gray-900 group-hover:text-blue-700 transition-colors">
|
||||||
|
{animal.animal_name}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
ID: {animal.animal_id_string} · {animal._count?.daily_statuses ?? 0} daily status entries
|
||||||
|
</div>
|
||||||
|
{infoFields.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-x-3 gap-y-0.5 mt-1">
|
||||||
|
{infoFields.map((f) => (
|
||||||
|
<span key={f.fieldId} className="text-xs text-gray-400">
|
||||||
|
<span className="font-medium text-gray-500">{f.label}:</span> {animal.subject_info[f.fieldId]}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => onEdit(animal)}>Edit</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ExperimentDetail() {
|
export default function ExperimentDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@@ -21,20 +60,48 @@ export default function ExperimentDetail() {
|
|||||||
|
|
||||||
const [showAddAnimal, setShowAddAnimal] = useState(false);
|
const [showAddAnimal, setShowAddAnimal] = useState(false);
|
||||||
const [editAnimal, setEditAnimal] = useState(null);
|
const [editAnimal, setEditAnimal] = useState(null);
|
||||||
const [deleteAnimal, setDeleteAnimal] = useState(null);
|
|
||||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
|
||||||
const [showTemplate, setShowTemplate] = useState(false);
|
const [showTemplate, setShowTemplate] = useState(false);
|
||||||
|
const [showSubjectTemplate, setShowSubjectTemplate] = useState(false);
|
||||||
|
const [subjectTemplate, setSubjectTemplate] = useState([]);
|
||||||
|
const [dailyTemplate, setDailyTemplate] = useState([]);
|
||||||
|
const [experimentStatuses, setExperimentStatuses] = useState([]);
|
||||||
|
|
||||||
|
const [displayMode, setDisplayMode] = useState(() => localStorage.getItem(`exp-display-mode-${id}`) ?? 'list');
|
||||||
|
const [sortField, setSortField] = useState(() => localStorage.getItem(`exp-subject-sort-${id}`) ?? '__name__');
|
||||||
|
const [sortDir, setSortDir] = useState(() => localStorage.getItem(`exp-subject-sort-dir-${id}`) ?? 'asc');
|
||||||
|
const [groupField, setGroupField] = useState(() => localStorage.getItem(`exp-subject-group-${id}`) ?? '__name__');
|
||||||
|
|
||||||
|
function updateDisplayMode(mode) {
|
||||||
|
setDisplayMode(mode);
|
||||||
|
localStorage.setItem(`exp-display-mode-${id}`, mode);
|
||||||
|
}
|
||||||
|
function updateSort(field, dir) {
|
||||||
|
setSortField(field);
|
||||||
|
setSortDir(dir);
|
||||||
|
localStorage.setItem(`exp-subject-sort-${id}`, field);
|
||||||
|
localStorage.setItem(`exp-subject-sort-dir-${id}`, dir);
|
||||||
|
}
|
||||||
|
function updateGroupField(field) {
|
||||||
|
setGroupField(field);
|
||||||
|
localStorage.setItem(`exp-subject-group-${id}`, field);
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const [exp, anims] = await Promise.all([
|
const [exp, anims, subjTmpl, dailyTmpl, expStatuses] = await Promise.all([
|
||||||
experimentsApi.get(id),
|
experimentsApi.get(id),
|
||||||
animalsApi.list(id),
|
animalsApi.list(id),
|
||||||
|
experimentsApi.getSubjectTemplate(id),
|
||||||
|
experimentsApi.getTemplate(id),
|
||||||
|
experimentsApi.getDailyStatuses(id),
|
||||||
]);
|
]);
|
||||||
setExperiment(exp);
|
setExperiment(exp);
|
||||||
setAnimals(anims);
|
setAnimals(anims);
|
||||||
|
setSubjectTemplate(subjTmpl);
|
||||||
|
setDailyTemplate(dailyTmpl);
|
||||||
|
setExperimentStatuses(expStatuses);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -56,21 +123,6 @@ export default function ExperimentDetail() {
|
|||||||
flash(editAnimal ? `Animal "${animal.animal_name}" updated.` : `Animal "${animal.animal_name}" added.`);
|
flash(editAnimal ? `Animal "${animal.animal_name}" updated.` : `Animal "${animal.animal_name}" added.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteAnimal() {
|
|
||||||
setDeleteLoading(true);
|
|
||||||
try {
|
|
||||||
await animalsApi.delete(deleteAnimal.id);
|
|
||||||
setDeleteAnimal(null);
|
|
||||||
load();
|
|
||||||
flash('Animal removed.');
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
setDeleteAnimal(null);
|
|
||||||
} finally {
|
|
||||||
setDeleteLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTemplateSaved(updatedTemplate) {
|
function handleTemplateSaved(updatedTemplate) {
|
||||||
setShowTemplate(false);
|
setShowTemplate(false);
|
||||||
if (updatedTemplate) {
|
if (updatedTemplate) {
|
||||||
@@ -80,6 +132,49 @@ export default function ExperimentDetail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sortedAnimals = useMemo(() => {
|
||||||
|
const sorted = [...animals].sort((a, b) => {
|
||||||
|
let av, bv;
|
||||||
|
if (sortField === '__name__') {
|
||||||
|
av = a.animal_name ?? '';
|
||||||
|
bv = b.animal_name ?? '';
|
||||||
|
} else if (sortField === '__id__') {
|
||||||
|
av = a.animal_id_string ?? '';
|
||||||
|
bv = b.animal_id_string ?? '';
|
||||||
|
} else {
|
||||||
|
av = a.subject_info?.[sortField] ?? '';
|
||||||
|
bv = b.subject_info?.[sortField] ?? '';
|
||||||
|
}
|
||||||
|
// numeric-aware comparison
|
||||||
|
const an = parseFloat(av), bn = parseFloat(bv);
|
||||||
|
const cmp = (!isNaN(an) && !isNaN(bn)) ? an - bn : String(av).localeCompare(String(bv));
|
||||||
|
return sortDir === 'asc' ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
return sorted;
|
||||||
|
}, [animals, sortField, sortDir, subjectTemplate]);
|
||||||
|
|
||||||
|
// Group mode: bucket sortedAnimals by the groupField value
|
||||||
|
const groupedAnimals = useMemo(() => {
|
||||||
|
if (displayMode !== 'group') return null;
|
||||||
|
const getVal = (animal) => {
|
||||||
|
if (groupField === '__name__') return animal.animal_name ?? '—';
|
||||||
|
if (groupField === '__id__') return animal.animal_id_string ?? '—';
|
||||||
|
const v = animal.subject_info?.[groupField];
|
||||||
|
return v != null && v !== '' ? String(v) : '—';
|
||||||
|
};
|
||||||
|
const buckets = new Map();
|
||||||
|
for (const animal of sortedAnimals) {
|
||||||
|
const key = getVal(animal);
|
||||||
|
if (!buckets.has(key)) buckets.set(key, []);
|
||||||
|
buckets.get(key).push(animal);
|
||||||
|
}
|
||||||
|
// Sort bucket keys numerically-aware
|
||||||
|
return [...buckets.entries()].sort(([a], [b]) => {
|
||||||
|
const an = parseFloat(a), bn = parseFloat(b);
|
||||||
|
return (!isNaN(an) && !isNaN(bn)) ? an - bn : a.localeCompare(b);
|
||||||
|
});
|
||||||
|
}, [sortedAnimals, displayMode, groupField]);
|
||||||
|
|
||||||
if (loading) return <div className="max-w-5xl mx-auto px-4 py-8 text-gray-400" aria-live="polite" aria-busy="true">Loading…</div>;
|
if (loading) return <div className="max-w-5xl mx-auto px-4 py-8 text-gray-400" aria-live="polite" aria-busy="true">Loading…</div>;
|
||||||
if (error) return (
|
if (error) return (
|
||||||
<div className="max-w-5xl mx-auto px-4 py-8">
|
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||||
@@ -104,25 +199,18 @@ export default function ExperimentDetail() {
|
|||||||
{animals.length} animal{animals.length !== 1 ? 's' : ''} enrolled
|
{animals.length} animal{animals.length !== 1 ? 's' : ''} enrolled
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="secondary" onClick={() => setShowTemplate(true)}>
|
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
||||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
|
||||||
</svg>
|
|
||||||
Record Template
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => setShowAddAnimal(true)}>+ Add Animal</Button>
|
<Button onClick={() => setShowAddAnimal(true)}>+ Add Animal</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{successMsg && <Alert type="success" message={successMsg} />}
|
{successMsg && <Alert type="success" message={successMsg} />}
|
||||||
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
|
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
|
||||||
|
|
||||||
{/* Template summary strip */}
|
{/* Template strips */}
|
||||||
{experiment.template && experiment.template.length > 0 && (
|
<div className="mb-5 space-y-2">
|
||||||
<div className="mb-5 flex flex-wrap gap-1.5">
|
{/* Daily record template */}
|
||||||
{experiment.template.map((f) => (
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide w-24 shrink-0">Daily record</span>
|
||||||
|
{(experiment.template ?? []).map((f) => (
|
||||||
<span
|
<span
|
||||||
key={f.key}
|
key={f.key}
|
||||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border ${
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border ${
|
||||||
@@ -135,12 +223,40 @@ export default function ExperimentDetail() {
|
|||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowTemplate(true)}
|
onClick={() => setShowTemplate(true)}
|
||||||
className="text-xs text-gray-400 hover:text-blue-600 underline underline-offset-2 transition-colors"
|
className="inline-flex items-center gap-1 text-xs text-gray-400 hover:text-blue-600 transition-colors"
|
||||||
>
|
>
|
||||||
edit
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536M9 13l6.586-6.586a2 2 0 112.828 2.828L11.828 15.828A2 2 0 0110 16.414V19h2.586a2 2 0 001.414-.586l6.5-6.5" />
|
||||||
|
</svg>
|
||||||
|
{(experiment.template ?? []).length === 0 ? 'Set up' : 'Edit'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
{/* Subject info template */}
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide w-24 shrink-0">Subject info</span>
|
||||||
|
{subjectTemplate.map((f) => (
|
||||||
|
<span
|
||||||
|
key={f.key}
|
||||||
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border ${
|
||||||
|
f.active ? 'bg-emerald-50 border-emerald-200 text-emerald-700' : 'bg-gray-100 border-gray-200 text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{f.active ? null : <span title="hidden">◌</span>}
|
||||||
|
{f.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSubjectTemplate(true)}
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-gray-400 hover:text-emerald-600 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536M9 13l6.586-6.586a2 2 0 112.828 2.828L11.828 15.828A2 2 0 0110 16.414V19h2.586a2 2 0 001.414-.586l6.5-6.5" />
|
||||||
|
</svg>
|
||||||
|
{subjectTemplate.length === 0 ? 'Set up' : 'Edit'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{animals.length === 0 ? (
|
{animals.length === 0 ? (
|
||||||
<div className="text-center py-16 border-2 border-dashed border-gray-200 rounded-xl">
|
<div className="text-center py-16 border-2 border-dashed border-gray-200 rounded-xl">
|
||||||
@@ -148,37 +264,136 @@ export default function ExperimentDetail() {
|
|||||||
<Button onClick={() => setShowAddAnimal(true)}>+ Add First Animal</Button>
|
<Button onClick={() => setShowAddAnimal(true)}>+ Add First Animal</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3">
|
<>
|
||||||
{animals.map((animal) => (
|
{/* Display controls */}
|
||||||
<div
|
<div className="flex items-center gap-3 mb-3 flex-wrap">
|
||||||
key={animal.id}
|
{/* Mode toggle */}
|
||||||
className="bg-white border border-gray-200 rounded-xl p-4 flex items-center justify-between hover:border-blue-300 hover:shadow-sm transition-all cursor-pointer group"
|
<div className="flex rounded border border-gray-200 overflow-hidden text-xs">
|
||||||
onClick={() => navigate(`/animals/${animal.id}`, { state: { template: experiment.template } })}
|
{[['list', 'List'], ['group', 'Group'], ['calendar', 'Calendar']].map(([mode, label]) => (
|
||||||
>
|
<button key={mode} type="button" onClick={() => updateDisplayMode(mode)}
|
||||||
<div>
|
className={`px-3 py-1 border-l border-gray-200 first:border-l-0 transition-colors
|
||||||
<div className="font-semibold text-gray-900 group-hover:text-blue-700 transition-colors">
|
${displayMode === mode ? 'bg-gray-700 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
|
||||||
{animal.animal_name}
|
{label}
|
||||||
</div>
|
</button>
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
ID: {animal.animal_id_string} · {animal._count?.daily_statuses ?? 0} daily status entries
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<Button size="sm" variant="secondary" onClick={() => setEditAnimal(animal)}>Edit</Button>
|
|
||||||
<Button size="sm" variant="danger" onClick={() => setDeleteAnimal(animal)}>Remove</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{displayMode === 'list' && (
|
||||||
|
<>
|
||||||
|
<span className="text-xs text-gray-400">Sort by</span>
|
||||||
|
<select value={sortField} onChange={(e) => updateSort(e.target.value, sortDir)}
|
||||||
|
className="border border-gray-200 rounded px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400">
|
||||||
|
<option value="__name__">Name</option>
|
||||||
|
<option value="__id__">Subject ID</option>
|
||||||
|
{subjectTemplate.filter((f) => f.active).map((f) => (
|
||||||
|
<option key={f.fieldId} value={f.fieldId}>{f.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button type="button" onClick={() => updateSort(sortField, sortDir === 'asc' ? 'desc' : 'asc')}
|
||||||
|
className="text-xs text-gray-500 hover:text-gray-800 border border-gray-200 rounded px-2 py-1 bg-white transition-colors">
|
||||||
|
{sortDir === 'asc' ? '↑ Asc' : '↓ Desc'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AuditLogSection tableName="animals" />
|
{displayMode === 'group' && (
|
||||||
|
<>
|
||||||
|
<span className="text-xs text-gray-400">Group by</span>
|
||||||
|
<select value={groupField} onChange={(e) => updateGroupField(e.target.value)}
|
||||||
|
className="border border-gray-200 rounded px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400">
|
||||||
|
<option value="__name__">Name</option>
|
||||||
|
<option value="__id__">Subject ID</option>
|
||||||
|
{subjectTemplate.filter((f) => f.active).map((f) => (
|
||||||
|
<option key={f.fieldId} value={f.fieldId}>{f.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="text-xs text-gray-400 ml-1">· sorted within by</span>
|
||||||
|
<select value={sortField} onChange={(e) => updateSort(e.target.value, sortDir)}
|
||||||
|
className="border border-gray-200 rounded px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400">
|
||||||
|
<option value="__name__">Name</option>
|
||||||
|
<option value="__id__">Subject ID</option>
|
||||||
|
{subjectTemplate.filter((f) => f.active).map((f) => (
|
||||||
|
<option key={f.fieldId} value={f.fieldId}>{f.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button type="button" onClick={() => updateSort(sortField, sortDir === 'asc' ? 'desc' : 'asc')}
|
||||||
|
className="text-xs text-gray-500 hover:text-gray-800 border border-gray-200 rounded px-2 py-1 bg-white transition-colors">
|
||||||
|
{sortDir === 'asc' ? '↑ Asc' : '↓ Desc'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{displayMode === 'list' && (
|
||||||
|
<AnimalCardList
|
||||||
|
animals={sortedAnimals}
|
||||||
|
subjectTemplate={subjectTemplate}
|
||||||
|
onNavigate={(animal) => navigate(`/animals/${animal.id}`, { state: { template: experiment.template } })}
|
||||||
|
onEdit={setEditAnimal}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{displayMode === 'group' && groupedAnimals && groupedAnimals.map(([groupValue, groupAnimals]) => {
|
||||||
|
const groupLabel = (() => {
|
||||||
|
if (groupField === '__name__' || groupField === '__id__') return groupValue;
|
||||||
|
return subjectTemplate.find((f) => f.fieldId === groupField)?.label
|
||||||
|
? `${subjectTemplate.find((f) => f.fieldId === groupField).label}: ${groupValue}`
|
||||||
|
: groupValue;
|
||||||
|
})();
|
||||||
|
return (
|
||||||
|
<div key={groupValue} className="mb-5">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">{groupLabel}</span>
|
||||||
|
<span className="text-xs text-gray-400">({groupAnimals.length})</span>
|
||||||
|
<div className="flex-1 border-t border-gray-100" />
|
||||||
|
</div>
|
||||||
|
<AnimalCardList
|
||||||
|
animals={groupAnimals}
|
||||||
|
subjectTemplate={subjectTemplate}
|
||||||
|
onNavigate={(animal) => navigate(`/animals/${animal.id}`, { state: { template: experiment.template } })}
|
||||||
|
onEdit={setEditAnimal}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{displayMode === 'calendar' && (
|
||||||
|
<ExperimentCalendar
|
||||||
|
experimentId={id}
|
||||||
|
template={dailyTemplate}
|
||||||
|
allStatuses={experimentStatuses}
|
||||||
|
animals={animals}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ExperimentAnalysisCharts
|
||||||
|
animals={animals}
|
||||||
|
experimentStatuses={experimentStatuses}
|
||||||
|
subjectTemplate={subjectTemplate}
|
||||||
|
dailyTemplate={dailyTemplate}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AuditLogSection tableName="animals" limit={5} />
|
||||||
|
|
||||||
{/* Template editor modal */}
|
{/* Template editor modal */}
|
||||||
<Modal isOpen={showTemplate} onClose={() => setShowTemplate(false)} title="Daily Record Template" size="lg">
|
<Modal isOpen={showTemplate} onClose={() => setShowTemplate(false)} title="Daily Record Template" size="full">
|
||||||
<TemplateEditor experimentId={id} onClose={handleTemplateSaved} />
|
<TemplateEditor experimentId={id} onClose={handleTemplateSaved} />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal isOpen={showSubjectTemplate} onClose={() => setShowSubjectTemplate(false)} title="Subject Info Template" size="full">
|
||||||
|
<TemplateEditor
|
||||||
|
experimentId={id}
|
||||||
|
loadFn={() => experimentsApi.getSubjectTemplate(id)}
|
||||||
|
saveFn={(fields) => experimentsApi.updateSubjectTemplate(id, fields)}
|
||||||
|
onClose={(updated) => {
|
||||||
|
setShowSubjectTemplate(false);
|
||||||
|
if (updated) { setSubjectTemplate(updated); flash('Subject info template updated.'); }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal isOpen={showAddAnimal} onClose={() => setShowAddAnimal(false)} title="Add Animal">
|
<Modal isOpen={showAddAnimal} onClose={() => setShowAddAnimal(false)} title="Add Animal">
|
||||||
<AnimalForm experimentId={id} onSuccess={handleAnimalSaved} onCancel={() => setShowAddAnimal(false)} />
|
<AnimalForm experimentId={id} onSuccess={handleAnimalSaved} onCancel={() => setShowAddAnimal(false)} />
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -187,14 +402,6 @@ export default function ExperimentDetail() {
|
|||||||
<AnimalForm existing={editAnimal} experimentId={id} onSuccess={handleAnimalSaved} onCancel={() => setEditAnimal(null)} />
|
<AnimalForm existing={editAnimal} experimentId={id} onSuccess={handleAnimalSaved} onCancel={() => setEditAnimal(null)} />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
isOpen={!!deleteAnimal}
|
|
||||||
onClose={() => setDeleteAnimal(null)}
|
|
||||||
onConfirm={handleDeleteAnimal}
|
|
||||||
loading={deleteLoading}
|
|
||||||
title="Remove Animal"
|
|
||||||
message={`Remove "${deleteAnimal?.animal_name}" and all its daily statuses from this experiment?`}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,5 +15,6 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
safelist: ['col-span-1', 'col-span-2', 'col-span-3', 'col-span-4', 'col-span-5', 'col-span-6', 'col-span-8', 'col-span-9', 'col-span-12'],
|
||||||
plugins: [],
|
plugins: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
||||||
|
import ExperimentCalendar from '../src/components/ExperimentCalendar';
|
||||||
|
import { experimentsApi } from '../src/api/client';
|
||||||
|
|
||||||
|
jest.mock('../src/api/client', () => ({
|
||||||
|
experimentsApi: {
|
||||||
|
getCalendar: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const EXP_ID = 'aaaaaaaa-0000-0000-0000-000000000001';
|
||||||
|
|
||||||
|
const TEMPLATE = [
|
||||||
|
{ fieldId: '00000000-0000-0000-0000-000000000002', key: 'vitals', label: 'Vitals', active: true, builtin: true },
|
||||||
|
{ fieldId: 'ww000000-0000-0000-0000-000000000099', key: 'weights', label: 'Weights', active: true, builtin: false },
|
||||||
|
{ fieldId: 'zz000000-0000-0000-0000-000000000001', key: 'notes', label: 'Notes', active: false, builtin: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ANIMALS = [
|
||||||
|
{ id: 'a1000000-0000-0000-0000-000000000001', animal_name: 'Rat A', animal_id_string: 'R001' },
|
||||||
|
{ id: 'a2000000-0000-0000-0000-000000000002', animal_name: 'Rat B', animal_id_string: 'R002' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function todayYYYYMM() {
|
||||||
|
const d = new Date();
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStatus(animalId, date, customFields = {}) {
|
||||||
|
return {
|
||||||
|
id: `s-${animalId}-${date}`,
|
||||||
|
animal_id: animalId,
|
||||||
|
date: `${date}T00:00:00.000Z`,
|
||||||
|
experiment_description: null,
|
||||||
|
vitals: null,
|
||||||
|
treatment: null,
|
||||||
|
notes: null,
|
||||||
|
custom_fields: customFields,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
experimentsApi.getCalendar.mockResolvedValue({ field: 'ww000000-0000-0000-0000-000000000099', days: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Rendering ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('ExperimentCalendar rendering', () => {
|
||||||
|
it('renders the current month and year', async () => {
|
||||||
|
render(
|
||||||
|
<ExperimentCalendar
|
||||||
|
experimentId={EXP_ID}
|
||||||
|
template={TEMPLATE}
|
||||||
|
allStatuses={[]}
|
||||||
|
animals={ANIMALS}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const now = new Date();
|
||||||
|
const monthLabel = now.toLocaleString('en-US', { month: 'long', year: 'numeric' });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(monthLabel)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders day-of-week headers', async () => {
|
||||||
|
render(
|
||||||
|
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
|
||||||
|
);
|
||||||
|
for (const d of ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']) {
|
||||||
|
expect(screen.getByText(d)).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the field selector with active template fields only', async () => {
|
||||||
|
render(
|
||||||
|
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(screen.getByTestId('field-select')).toBeInTheDocument());
|
||||||
|
const options = screen.getAllByRole('option');
|
||||||
|
const labels = options.map((o) => o.textContent);
|
||||||
|
expect(labels).toContain('Vitals');
|
||||||
|
expect(labels).toContain('Weights');
|
||||||
|
expect(labels).not.toContain('Notes'); // inactive
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Default field selection ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('default field selection', () => {
|
||||||
|
it('defaults to the field whose label contains "weight"', async () => {
|
||||||
|
render(
|
||||||
|
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(experimentsApi.getCalendar).toHaveBeenCalledWith(
|
||||||
|
EXP_ID,
|
||||||
|
'ww000000-0000-0000-0000-000000000099',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to first field if none contains "weight"', async () => {
|
||||||
|
const noWeightTemplate = [
|
||||||
|
{ fieldId: 'ff000000-0000-0000-0000-000000000001', key: 'treatment', label: 'Treatment', active: true, builtin: true },
|
||||||
|
{ fieldId: 'ff000000-0000-0000-0000-000000000002', key: 'vitals', label: 'Vitals', active: true, builtin: true },
|
||||||
|
];
|
||||||
|
render(
|
||||||
|
<ExperimentCalendar experimentId={EXP_ID} template={noWeightTemplate} allStatuses={[]} animals={ANIMALS} />,
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(experimentsApi.getCalendar).toHaveBeenCalledWith(EXP_ID, 'treatment');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Count badges ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('count badges', () => {
|
||||||
|
it('shows a count badge on days returned by the API', async () => {
|
||||||
|
const today = new Date();
|
||||||
|
const year = today.getFullYear();
|
||||||
|
const month = String(today.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(today.getDate()).padStart(2, '0');
|
||||||
|
const dateStr = `${year}-${month}-${day}`;
|
||||||
|
|
||||||
|
experimentsApi.getCalendar.mockResolvedValue({
|
||||||
|
field: 'ww000000-0000-0000-0000-000000000099',
|
||||||
|
days: { [dateStr]: 3 },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`count-${dateStr}`)).toHaveTextContent('3');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show count badge on days with no data', async () => {
|
||||||
|
const today = new Date();
|
||||||
|
const year = today.getFullYear();
|
||||||
|
const month = String(today.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(today.getDate()).padStart(2, '0');
|
||||||
|
const dateStr = `${year}-${month}-${day}`;
|
||||||
|
|
||||||
|
experimentsApi.getCalendar.mockResolvedValue({ field: 'vitals', days: {} });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(experimentsApi.getCalendar).toHaveBeenCalled());
|
||||||
|
expect(screen.queryByTestId(`count-${dateStr}`)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Month navigation ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('month navigation', () => {
|
||||||
|
it('moves to the previous month on ‹ click', async () => {
|
||||||
|
render(
|
||||||
|
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
|
||||||
|
);
|
||||||
|
const now = new Date();
|
||||||
|
const prev = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||||
|
const prevLabel = prev.toLocaleString('en-US', { month: 'long', year: 'numeric' });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText('Previous month'));
|
||||||
|
await waitFor(() => expect(screen.getByText(prevLabel)).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves to the next month on › click', async () => {
|
||||||
|
render(
|
||||||
|
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
|
||||||
|
);
|
||||||
|
const now = new Date();
|
||||||
|
const next = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||||
|
const nextLabel = next.toLocaleString('en-US', { month: 'long', year: 'numeric' });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText('Next month'));
|
||||||
|
await waitFor(() => expect(screen.getByText(nextLabel)).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-fetches calendar data after field change', async () => {
|
||||||
|
render(
|
||||||
|
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(experimentsApi.getCalendar).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByTestId('field-select'), { target: { value: 'vitals' } });
|
||||||
|
|
||||||
|
await waitFor(() => expect(experimentsApi.getCalendar).toHaveBeenCalledTimes(2));
|
||||||
|
expect(experimentsApi.getCalendar).toHaveBeenLastCalledWith(EXP_ID, 'vitals');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Day click → modal ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('day click modal', () => {
|
||||||
|
it('opens a modal showing subject data when a day with data is clicked', async () => {
|
||||||
|
const today = new Date();
|
||||||
|
const year = today.getFullYear();
|
||||||
|
const month = String(today.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(today.getDate()).padStart(2, '0');
|
||||||
|
const dateStr = `${year}-${month}-${day}`;
|
||||||
|
|
||||||
|
experimentsApi.getCalendar.mockResolvedValue({
|
||||||
|
field: 'ww000000-0000-0000-0000-000000000099',
|
||||||
|
days: { [dateStr]: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const statuses = [
|
||||||
|
makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, {
|
||||||
|
'ww000000-0000-0000-0000-000000000099': '325',
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ExperimentCalendar
|
||||||
|
experimentId={EXP_ID}
|
||||||
|
template={TEMPLATE}
|
||||||
|
allStatuses={statuses}
|
||||||
|
animals={ANIMALS}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId(`day-${dateStr}`)).not.toBeDisabled());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId(`day-${dateStr}`));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Rat A')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('325')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not open a modal when clicking a day without data', async () => {
|
||||||
|
const today = new Date();
|
||||||
|
const year = today.getFullYear();
|
||||||
|
const month = String(today.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(today.getDate()).padStart(2, '0');
|
||||||
|
const dateStr = `${year}-${month}-${day}`;
|
||||||
|
|
||||||
|
experimentsApi.getCalendar.mockResolvedValue({ field: 'vitals', days: {} });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(experimentsApi.getCalendar).toHaveBeenCalled());
|
||||||
|
|
||||||
|
// Day button is disabled — click should not open modal
|
||||||
|
const dayBtn = screen.getByTestId(`day-${dateStr}`);
|
||||||
|
expect(dayBtn).toBeDisabled();
|
||||||
|
fireEvent.click(dayBtn);
|
||||||
|
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows all subjects with data on the selected day', async () => {
|
||||||
|
const today = new Date();
|
||||||
|
const year = today.getFullYear();
|
||||||
|
const month = String(today.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(today.getDate()).padStart(2, '0');
|
||||||
|
const dateStr = `${year}-${month}-${day}`;
|
||||||
|
|
||||||
|
experimentsApi.getCalendar.mockResolvedValue({
|
||||||
|
field: 'ww000000-0000-0000-0000-000000000099',
|
||||||
|
days: { [dateStr]: 2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const statuses = [
|
||||||
|
makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '320' }),
|
||||||
|
makeStatus('a2000000-0000-0000-0000-000000000002', dateStr, { 'ww000000-0000-0000-0000-000000000099': '310' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ExperimentCalendar
|
||||||
|
experimentId={EXP_ID}
|
||||||
|
template={TEMPLATE}
|
||||||
|
allStatuses={statuses}
|
||||||
|
animals={ANIMALS}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId(`day-${dateStr}`)).not.toBeDisabled());
|
||||||
|
fireEvent.click(screen.getByTestId(`day-${dateStr}`));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Rat A')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Rat B')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import {
|
||||||
|
parseCSVHeaders,
|
||||||
|
parseCSV,
|
||||||
|
getUniqueNoteValues,
|
||||||
|
runAnalysis,
|
||||||
|
} from '../src/lib/csvAnalysis';
|
||||||
|
|
||||||
|
// ── Fixtures ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Minimal CSV mimicking the real experiment file structure
|
||||||
|
const FIXTURE_CSV = `timestamp,frame_number,frame_line_status,note
|
||||||
|
0.0,1,10,
|
||||||
|
1.0,2,10,f
|
||||||
|
2.0,3,10,s
|
||||||
|
3.0,4,10,s
|
||||||
|
4.0,5,10,f
|
||||||
|
5.0,6,10,
|
||||||
|
6.0,7,10,s
|
||||||
|
7.0,8,10,f
|
||||||
|
8.0,9,10,f
|
||||||
|
9.0,10,10,s`;
|
||||||
|
|
||||||
|
// Same data but with Windows-style line endings
|
||||||
|
const FIXTURE_CSV_CRLF = FIXTURE_CSV.replace(/\n/g, '\r\n');
|
||||||
|
|
||||||
|
// CSV with quoted field containing a comma
|
||||||
|
const FIXTURE_CSV_QUOTED = `timestamp,note\n1.0,"hello, world"\n2.0,plain`;
|
||||||
|
|
||||||
|
// Out-of-order timestamps (should be sorted)
|
||||||
|
const FIXTURE_UNSORTED = `timestamp,note
|
||||||
|
3.0,s
|
||||||
|
1.0,f
|
||||||
|
2.0,s`;
|
||||||
|
|
||||||
|
const CLASSIFICATIONS = { f: 'Failure', s: 'Success' };
|
||||||
|
|
||||||
|
// ── parseCSVHeaders ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('parseCSVHeaders', () => {
|
||||||
|
test('returns column names from first line', () => {
|
||||||
|
expect(parseCSVHeaders(FIXTURE_CSV)).toEqual([
|
||||||
|
'timestamp', 'frame_number', 'frame_line_status', 'note',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles CRLF line endings', () => {
|
||||||
|
expect(parseCSVHeaders(FIXTURE_CSV_CRLF)).toEqual([
|
||||||
|
'timestamp', 'frame_number', 'frame_line_status', 'note',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles single-line CSV (no newline)', () => {
|
||||||
|
expect(parseCSVHeaders('a,b,c')).toEqual(['a', 'b', 'c']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── parseCSV ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('parseCSV', () => {
|
||||||
|
test('returns headers and rows', () => {
|
||||||
|
const { headers, rows } = parseCSV(FIXTURE_CSV);
|
||||||
|
expect(headers).toEqual(['timestamp', 'frame_number', 'frame_line_status', 'note']);
|
||||||
|
expect(rows).toHaveLength(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('row values are keyed by header name', () => {
|
||||||
|
const { rows } = parseCSV(FIXTURE_CSV);
|
||||||
|
expect(rows[0]).toEqual({ timestamp: '0.0', frame_number: '1', frame_line_status: '10', note: '' });
|
||||||
|
expect(rows[1]).toEqual({ timestamp: '1.0', frame_number: '2', frame_line_status: '10', note: 'f' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles CRLF line endings', () => {
|
||||||
|
const { rows } = parseCSV(FIXTURE_CSV_CRLF);
|
||||||
|
expect(rows).toHaveLength(10);
|
||||||
|
expect(rows[1].note).toBe('f');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles quoted fields containing commas', () => {
|
||||||
|
const { rows } = parseCSV(FIXTURE_CSV_QUOTED);
|
||||||
|
expect(rows[0].note).toBe('hello, world');
|
||||||
|
expect(rows[1].note).toBe('plain');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips blank lines', () => {
|
||||||
|
const csv = 'a,b\n1,2\n\n3,4\n';
|
||||||
|
const { rows } = parseCSV(csv);
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── getUniqueNoteValues ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('getUniqueNoteValues', () => {
|
||||||
|
test('returns sorted unique non-empty values', () => {
|
||||||
|
const { rows } = parseCSV(FIXTURE_CSV);
|
||||||
|
expect(getUniqueNoteValues(rows, 'note')).toEqual(['f', 's']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores empty strings', () => {
|
||||||
|
const rows = [{ note: '' }, { note: 'a' }, { note: '' }, { note: 'b' }];
|
||||||
|
expect(getUniqueNoteValues(rows, 'note')).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('trims whitespace before deduplication', () => {
|
||||||
|
const rows = [{ note: ' a ' }, { note: 'a' }, { note: 'b' }];
|
||||||
|
expect(getUniqueNoteValues(rows, 'note')).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns empty array when no non-empty notes', () => {
|
||||||
|
const rows = [{ note: '' }, { note: '' }];
|
||||||
|
expect(getUniqueNoteValues(rows, 'note')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── runAnalysis ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('runAnalysis', () => {
|
||||||
|
let result;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const { rows } = parseCSV(FIXTURE_CSV);
|
||||||
|
result = runAnalysis(rows, 'timestamp', 'note', CLASSIFICATIONS);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fixture note sequence (sorted by timestamp, empty rows excluded):
|
||||||
|
// t=1 f, t=2 s, t=3 s, t=4 f, t=6 s, t=7 f, t=8 f, t=9 s
|
||||||
|
// Categories: F S S F S F F S
|
||||||
|
|
||||||
|
test('totalNoteRows counts only rows with non-empty note', () => {
|
||||||
|
expect(result.totalNoteRows).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sequence preserves order and maps to categories', () => {
|
||||||
|
const notes = result.sequence.map(r => r.note);
|
||||||
|
expect(notes).toEqual(['f', 's', 's', 'f', 's', 'f', 'f', 's']);
|
||||||
|
|
||||||
|
const cats = result.sequence.map(r => r.category);
|
||||||
|
expect(cats).toEqual(['Failure', 'Success', 'Success', 'Failure', 'Success', 'Failure', 'Failure', 'Success']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('categoryCounts totals are correct', () => {
|
||||||
|
expect(result.categoryCounts['Failure'].total).toBe(4);
|
||||||
|
expect(result.categoryCounts['Success'].total).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('categoryCounts byNote breakdown is correct', () => {
|
||||||
|
expect(result.categoryCounts['Failure'].byNote).toEqual({ f: 4 });
|
||||||
|
expect(result.categoryCounts['Success'].byNote).toEqual({ s: 4 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runs (run-length encoding) are correct', () => {
|
||||||
|
// F S S F S F F S → F×1, S×2, F×1, S×1, F×2, S×1
|
||||||
|
expect(result.runs).toEqual([
|
||||||
|
{ category: 'Failure', length: 1 },
|
||||||
|
{ category: 'Success', length: 2 },
|
||||||
|
{ category: 'Failure', length: 1 },
|
||||||
|
{ category: 'Success', length: 1 },
|
||||||
|
{ category: 'Failure', length: 2 },
|
||||||
|
{ category: 'Success', length: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('consecutiveStats totalRuns', () => {
|
||||||
|
expect(result.consecutiveStats['Failure'].totalRuns).toBe(3);
|
||||||
|
expect(result.consecutiveStats['Success'].totalRuns).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('consecutiveStats maxRun', () => {
|
||||||
|
expect(result.consecutiveStats['Failure'].maxRun).toBe(2);
|
||||||
|
expect(result.consecutiveStats['Success'].maxRun).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('consecutiveStats avgRun', () => {
|
||||||
|
// Failure runs: [1,1,2] → avg = 4/3
|
||||||
|
expect(result.consecutiveStats['Failure'].avgRun).toBeCloseTo(4 / 3);
|
||||||
|
// Success runs: [2,1,1] → avg = 4/3
|
||||||
|
expect(result.consecutiveStats['Success'].avgRun).toBeCloseTo(4 / 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('consecutiveStats runLengths array', () => {
|
||||||
|
expect(result.consecutiveStats['Failure'].runLengths).toEqual([1, 1, 2]);
|
||||||
|
expect(result.consecutiveStats['Success'].runLengths).toEqual([2, 1, 1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runAnalysis — sorting', () => {
|
||||||
|
test('sorts rows by timestamp before building sequence', () => {
|
||||||
|
const { rows } = parseCSV(FIXTURE_UNSORTED);
|
||||||
|
// Input order: s(3), f(1), s(2) — sorted order: f(1), s(2), s(3)
|
||||||
|
const result = runAnalysis(rows, 'timestamp', 'note', CLASSIFICATIONS);
|
||||||
|
expect(result.sequence.map(r => r.note)).toEqual(['f', 's', 's']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runAnalysis — unclassified notes', () => {
|
||||||
|
test('unclassified notes appear in sequence with null category', () => {
|
||||||
|
const { rows } = parseCSV(FIXTURE_CSV);
|
||||||
|
// Only classify 'f'; leave 's' unclassified
|
||||||
|
const result = runAnalysis(rows, 'timestamp', 'note', { f: 'Failure' });
|
||||||
|
const nullCats = result.sequence.filter(r => r.category === null);
|
||||||
|
expect(nullCats.length).toBe(4); // 4 's' notes
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unclassified notes are excluded from categoryCounts', () => {
|
||||||
|
const { rows } = parseCSV(FIXTURE_CSV);
|
||||||
|
const result = runAnalysis(rows, 'timestamp', 'note', { f: 'Failure' });
|
||||||
|
expect(result.categoryCounts['Success']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unclassified notes do not break or extend runs', () => {
|
||||||
|
// Sequence with 'x' unclassified: f x f → should still be one Failure run of 2
|
||||||
|
const csv = 'ts,note\n1.0,f\n2.0,x\n3.0,f';
|
||||||
|
const { rows } = parseCSV(csv);
|
||||||
|
// 'x' is unclassified, 'f' → Failure
|
||||||
|
// After skipping 'x': f, f → one run of Failure×2
|
||||||
|
const result = runAnalysis(rows, 'ts', 'note', { f: 'Failure' });
|
||||||
|
expect(result.runs).toEqual([{ category: 'Failure', length: 2 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runAnalysis — multiple note values per category', () => {
|
||||||
|
test('groups multiple note values under one category', () => {
|
||||||
|
// The user's example: ss and s both belong to Success
|
||||||
|
const csv = 'ts,note\n1.0,ss\n2.0,s\n3.0,s';
|
||||||
|
const { rows } = parseCSV(csv);
|
||||||
|
const result = runAnalysis(rows, 'ts', 'note', { ss: 'Success', s: 'Success' });
|
||||||
|
expect(result.categoryCounts['Success'].total).toBe(3);
|
||||||
|
expect(result.categoryCounts['Success'].byNote).toEqual({ ss: 1, s: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('run-length encoding with multiple note values in same category', () => {
|
||||||
|
const csv = 'ts,note\n1.0,ss\n2.0,s\n3.0,f';
|
||||||
|
const { rows } = parseCSV(csv);
|
||||||
|
const result = runAnalysis(rows, 'ts', 'note', { ss: 'Success', s: 'Success', f: 'Failure' });
|
||||||
|
// ss and s are both Success → run of 2, then Failure run of 1
|
||||||
|
expect(result.runs).toEqual([
|
||||||
|
{ category: 'Success', length: 2 },
|
||||||
|
{ category: 'Failure', length: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runAnalysis — edge cases', () => {
|
||||||
|
test('empty rows returns zero totals', () => {
|
||||||
|
const result = runAnalysis([], 'timestamp', 'note', CLASSIFICATIONS);
|
||||||
|
expect(result.totalNoteRows).toBe(0);
|
||||||
|
expect(result.sequence).toEqual([]);
|
||||||
|
expect(result.runs).toEqual([]);
|
||||||
|
expect(result.categoryCounts).toEqual({});
|
||||||
|
expect(result.consecutiveStats).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all notes unclassified', () => {
|
||||||
|
const { rows } = parseCSV(FIXTURE_CSV);
|
||||||
|
const result = runAnalysis(rows, 'timestamp', 'note', {});
|
||||||
|
expect(result.categoryCounts).toEqual({});
|
||||||
|
expect(result.runs).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('single note row', () => {
|
||||||
|
const csv = 'ts,note\n1.0,f';
|
||||||
|
const { rows } = parseCSV(csv);
|
||||||
|
const result = runAnalysis(rows, 'ts', 'note', { f: 'Failure' });
|
||||||
|
expect(result.totalNoteRows).toBe(1);
|
||||||
|
expect(result.runs).toEqual([{ category: 'Failure', length: 1 }]);
|
||||||
|
expect(result.consecutiveStats['Failure'].maxRun).toBe(1);
|
||||||
|
expect(result.consecutiveStats['Failure'].avgRun).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all same category', () => {
|
||||||
|
const csv = 'ts,note\n1.0,f\n2.0,f\n3.0,f';
|
||||||
|
const { rows } = parseCSV(csv);
|
||||||
|
const result = runAnalysis(rows, 'ts', 'note', { f: 'Failure' });
|
||||||
|
expect(result.runs).toEqual([{ category: 'Failure', length: 3 }]);
|
||||||
|
expect(result.consecutiveStats['Failure'].maxRun).toBe(3);
|
||||||
|
expect(result.consecutiveStats['Failure'].totalRuns).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user