From 9535f86970e59af67f18618d167367bdb3c9b06e Mon Sep 17 00:00:00 2001 From: Experiments DB Dev Date: Thu, 23 Apr 2026 09:26:35 -0400 Subject: [PATCH] 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= 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 --- .../migration.sql | 2 + .../migration.sql | 21 + .../migration.sql | 1 + .../migration.sql | 1 + backend/prisma/schema.prisma | 39 +- backend/src/app.js | 13 + backend/src/lib/defaultTemplate.js | 26 +- backend/src/routes/analyses.js | 79 +++ backend/src/routes/animals.js | 4 +- backend/src/routes/dailyStatuses.js | 47 +- backend/src/routes/experiments.js | 187 ++++++ backend/tests/calendar.test.js | 129 ++++ docker-compose.yml | 3 + frontend/package-lock.json | 600 ++++++++++++++++- frontend/package.json | 1 + frontend/src/App.jsx | 24 +- frontend/src/api/client.js | 18 + frontend/src/components/AnalysisCharts.jsx | 413 ++++++++++++ frontend/src/components/AuditLogSection.jsx | 14 +- frontend/src/components/CsvAnalysis.jsx | 602 ++++++++++++++++++ frontend/src/components/DailyStatusForm.jsx | 202 ++++-- .../src/components/ExperimentCalendar.jsx | 225 +++++++ frontend/src/components/RunSequenceView.jsx | 343 ++++++++++ frontend/src/components/SubjectInfoForm.jsx | 146 +++++ frontend/src/components/TemplateEditor.jsx | 412 +++++++++--- frontend/src/components/ui/ConfirmDialog.jsx | 29 +- frontend/src/components/ui/Modal.jsx | 2 +- frontend/src/lib/csvAnalysis.js | 220 +++++++ frontend/src/pages/AnimalDetail.jsx | 477 ++++++++++++-- frontend/src/pages/DailyStatusDetail.jsx | 601 +++++++++++++++++ frontend/src/pages/ExperimentDetail.jsx | 343 ++++++++-- frontend/tailwind.config.js | 1 + frontend/tests/ExperimentCalendar.test.jsx | 297 +++++++++ frontend/tests/csvAnalysis.test.js | 278 ++++++++ 34 files changed, 5460 insertions(+), 340 deletions(-) create mode 100644 backend/prisma/migrations/20250417000000_add_subject_info/migration.sql create mode 100644 backend/prisma/migrations/20250419000000_add_daily_analyses/migration.sql create mode 100644 backend/prisma/migrations/20250419010000_add_session_end_ts/migration.sql create mode 100644 backend/prisma/migrations/20250419020000_add_analysis_summary/migration.sql create mode 100644 backend/src/routes/analyses.js create mode 100644 backend/tests/calendar.test.js create mode 100644 frontend/src/components/AnalysisCharts.jsx create mode 100644 frontend/src/components/CsvAnalysis.jsx create mode 100644 frontend/src/components/ExperimentCalendar.jsx create mode 100644 frontend/src/components/RunSequenceView.jsx create mode 100644 frontend/src/components/SubjectInfoForm.jsx create mode 100644 frontend/src/lib/csvAnalysis.js create mode 100644 frontend/src/pages/DailyStatusDetail.jsx create mode 100644 frontend/tests/ExperimentCalendar.test.jsx create mode 100644 frontend/tests/csvAnalysis.test.js diff --git a/backend/prisma/migrations/20250417000000_add_subject_info/migration.sql b/backend/prisma/migrations/20250417000000_add_subject_info/migration.sql new file mode 100644 index 0000000..a0170f4 --- /dev/null +++ b/backend/prisma/migrations/20250417000000_add_subject_info/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "experiments" ADD COLUMN "subject_info_template" JSONB NOT NULL DEFAULT '[]'; +ALTER TABLE "animals" ADD COLUMN "subject_info" JSONB; diff --git a/backend/prisma/migrations/20250419000000_add_daily_analyses/migration.sql b/backend/prisma/migrations/20250419000000_add_daily_analyses/migration.sql new file mode 100644 index 0000000..51185ef --- /dev/null +++ b/backend/prisma/migrations/20250419000000_add_daily_analyses/migration.sql @@ -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; diff --git a/backend/prisma/migrations/20250419010000_add_session_end_ts/migration.sql b/backend/prisma/migrations/20250419010000_add_session_end_ts/migration.sql new file mode 100644 index 0000000..47eb986 --- /dev/null +++ b/backend/prisma/migrations/20250419010000_add_session_end_ts/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "daily_analyses" ADD COLUMN "session_end_ts" DOUBLE PRECISION; diff --git a/backend/prisma/migrations/20250419020000_add_analysis_summary/migration.sql b/backend/prisma/migrations/20250419020000_add_analysis_summary/migration.sql new file mode 100644 index 0000000..ea12a0d --- /dev/null +++ b/backend/prisma/migrations/20250419020000_add_analysis_summary/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "daily_statuses" ADD COLUMN "analysis_summary" JSONB; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 5c861dc..4700952 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -9,11 +9,12 @@ datasource db { } model Experiment { - id String @id @default(uuid()) - title String - created_at DateTime @default(now()) - template Json @default("[]") - animals Animal[] + id String @id @default(uuid()) + title String + created_at DateTime @default(now()) + template Json @default("[]") + subject_info_template Json @default("[]") + animals Animal[] @@map("experiments") } @@ -23,6 +24,7 @@ model Animal { experiment_id String animal_id_string String animal_name String + subject_info Json? experiment Experiment @relation(fields: [experiment_id], references: [id], onDelete: Cascade) daily_statuses DailyStatus[] @@ -30,19 +32,40 @@ model Animal { } model DailyStatus { - id String @id @default(uuid()) + id String @id @default(uuid()) animal_id String - date DateTime @db.Date + date DateTime @db.Date experiment_description String? vitals String? treatment String? notes String? custom_fields Json? - animal Animal @relation(fields: [animal_id], references: [id], onDelete: Cascade) + analysis_summary Json? + animal Animal @relation(fields: [animal_id], references: [id], onDelete: Cascade) + analyses DailyAnalysis[] @@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 { id String @id @default(uuid()) table_name String diff --git a/backend/src/app.js b/backend/src/app.js index 4a410db..b66cd70 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -9,6 +9,7 @@ const experimentsRouter = require('./routes/experiments'); const animalsRouter = require('./routes/animals'); const dailyStatusesRouter = require('./routes/dailyStatuses'); const auditLogsRouter = require('./routes/auditLogs'); +const analysesRouter = require('./routes/analyses'); const app = express(); @@ -43,6 +44,17 @@ app.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); +// DB info — returns database timezone (no rate limit) +const prisma = require('./lib/prisma'); +app.get('/api/info', async (req, res) => { + try { + const [{ timezone }] = await prisma.$queryRaw`SELECT current_setting('TimeZone') AS timezone`; + res.json({ dbTimezone: timezone }); + } catch { + res.json({ dbTimezone: null }); + } +}); + // Apply rate limiter to all API routes app.use('/api', apiLimiter); @@ -50,6 +62,7 @@ app.use('/api/experiments', experimentsRouter); app.use('/api/animals', animalsRouter); app.use('/api/daily-statuses', dailyStatusesRouter); app.use('/api/audit-logs', auditLogsRouter); +app.use('/api', analysesRouter); app.use((req, res) => { res.status(404).json({ error: `Route ${req.method} ${req.path} not found` }); diff --git a/backend/src/lib/defaultTemplate.js b/backend/src/lib/defaultTemplate.js index 7b078f8..824cc07 100644 --- a/backend/src/lib/defaultTemplate.js +++ b/backend/src/lib/defaultTemplate.js @@ -1,22 +1,20 @@ /** * The default daily-record template applied to every new experiment. * - * Builtin fields have FIXED fieldIds so they are stable across all experiments - * and can never be confused with user-created fields, even if a user creates a - * custom field with the same label. - * - * `fieldId` — stable UUID used as the storage key; never changes even if - * the field is renamed, reordered, or temporarily removed - * `key` — human-readable slug (display only, not used for storage) - * `builtin` — true: value stored in the dedicated column of daily_statuses - * false: value stored in daily_statuses.custom_fields[fieldId] - * `active` — false: field is hidden (data preserved) + * `fieldId` — stable UUID; used as the storage key in custom_fields. + * Builtin fields have fixed canonical UUIDs so they are + * recognised across all experiments. + * `key` — human-readable slug (display / validation only) + * `builtin` — true → value stored in the dedicated DB column + * false → value stored in daily_statuses.custom_fields[fieldId] + * `active` — false → hidden from forms (data preserved) + * `tags` — quick-fill presets, stored per field per experiment */ const DEFAULT_TEMPLATE = [ - { fieldId: '00000000-0000-0000-0000-000000000001', key: 'experiment_description', label: 'Experiment Description', type: 'textarea', builtin: true, active: true }, - { fieldId: '00000000-0000-0000-0000-000000000002', key: 'vitals', label: 'Vitals', type: 'text', builtin: true, active: true }, - { fieldId: '00000000-0000-0000-0000-000000000003', key: 'treatment', label: 'Treatment', type: 'text', builtin: true, active: true }, - { fieldId: '00000000-0000-0000-0000-000000000004', key: 'notes', label: 'Notes', type: 'textarea', builtin: true, active: true }, + { fieldId: '00000000-0000-0000-0000-000000000001', key: 'experiment_description', label: 'Experiment Description', type: 'textarea', builtin: true, active: true, tags: [] }, + { fieldId: '00000000-0000-0000-0000-000000000002', key: 'vitals', label: 'Vitals', type: 'text', builtin: true, active: true, tags: [] }, + { fieldId: '00000000-0000-0000-0000-000000000003', key: 'treatment', label: 'Treatment', type: 'text', builtin: true, active: true, tags: [] }, + { fieldId: '00000000-0000-0000-0000-000000000004', key: 'notes', label: 'Notes', type: 'textarea', builtin: true, active: true, tags: [] }, ]; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; diff --git a/backend/src/routes/analyses.js b/backend/src/routes/analyses.js new file mode 100644 index 0000000..a1e2b20 --- /dev/null +++ b/backend/src/routes/analyses.js @@ -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; diff --git a/backend/src/routes/animals.js b/backend/src/routes/animals.js index 2d29d81..0c645b8 100644 --- a/backend/src/routes/animals.js +++ b/backend/src/routes/animals.js @@ -13,6 +13,7 @@ const animalValidation = [ const animalUpdateValidation = [ body('animal_id_string').optional().trim().notEmpty().withMessage('animal_id_string cannot be empty'), body('animal_name').optional().trim().notEmpty().withMessage('animal_name cannot be empty'), + body('subject_info').optional({ nullable: true }).isObject().withMessage('subject_info must be an object'), ]; // GET /api/animals?experimentId= — list for experiment @@ -80,10 +81,11 @@ router.put( validateRequest, async (req, res, next) => { try { - const { animal_id_string, animal_name } = req.body; + const { animal_id_string, animal_name, subject_info } = req.body; const data = {}; if (animal_id_string !== undefined) data.animal_id_string = animal_id_string; if (animal_name !== undefined) data.animal_name = animal_name; + if (subject_info !== undefined) data.subject_info = subject_info; const animal = await prisma.animal.update({ where: { id: req.params.id }, diff --git a/backend/src/routes/dailyStatuses.js b/backend/src/routes/dailyStatuses.js index efb09eb..e35c0ae 100644 --- a/backend/src/routes/dailyStatuses.js +++ b/backend/src/routes/dailyStatuses.js @@ -7,20 +7,20 @@ const { validateRequest } = require('../middleware/errorHandler'); const statusValidation = [ body('animal_id').notEmpty().withMessage('animal_id is required').matches(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i).withMessage('animal_id must be a valid UUID'), body('date').notEmpty().withMessage('date is required').isISO8601().withMessage('date must be a valid ISO 8601 date (YYYY-MM-DD)'), - body('experiment_description').optional().isString(), - body('vitals').optional().isString(), - body('treatment').optional().isString(), - body('notes').optional().isString(), - body('custom_fields').optional().isObject().withMessage('custom_fields must be an object'), + body('experiment_description').optional({ nullable: true }).isString(), + body('vitals').optional({ nullable: true }).isString(), + body('treatment').optional({ nullable: true }).isString(), + body('notes').optional({ nullable: true }).isString(), + body('custom_fields').optional({ nullable: true }).isObject().withMessage('custom_fields must be an object'), ]; const statusUpdateValidation = [ body('date').optional().isISO8601().withMessage('date must be a valid ISO 8601 date'), - body('experiment_description').optional().isString(), - body('vitals').optional().isString(), - body('treatment').optional().isString(), - body('notes').optional().isString(), - body('custom_fields').optional().isObject().withMessage('custom_fields must be an object'), + body('experiment_description').optional({ nullable: true }).isString(), + body('vitals').optional({ nullable: true }).isString(), + body('treatment').optional({ nullable: true }).isString(), + body('notes').optional({ nullable: true }).isString(), + body('custom_fields').optional({ nullable: true }).isObject().withMessage('custom_fields must be an object'), ]; // GET /api/daily-statuses?animalId= @@ -98,6 +98,33 @@ router.put( } ); +// PUT /api/daily-statuses/:id/analysis-summary +router.put('/:id/analysis-summary', async (req, res, next) => { + try { + const status = await prisma.dailyStatus.findUnique({ where: { id: req.params.id } }); + if (!status) return res.status(404).json({ error: 'Daily status not found' }); + + const { counts, total, success_rate, source_analysis_id } = req.body; + if (!counts || total == null) { + return res.status(422).json({ error: 'counts and total are required' }); + } + + const summary = { + counts, + total, + success_rate: success_rate ?? null, + source_analysis_id: source_analysis_id ?? null, + computed_at: new Date().toISOString(), + }; + + const updated = await prisma.dailyStatus.update({ + where: { id: req.params.id }, + data: { analysis_summary: summary }, + }); + res.json(updated); + } catch (err) { next(err); } +}); + // DELETE /api/daily-statuses/:id router.delete( '/:id', diff --git a/backend/src/routes/experiments.js b/backend/src/routes/experiments.js index 9b4ceca..2e9f106 100644 --- a/backend/src/routes/experiments.js +++ b/backend/src/routes/experiments.js @@ -55,6 +55,33 @@ function validateTemplate(template) { return `field "${field.key}" is a builtin field and cannot be made non-builtin`; if (!BUILTIN_KEYS.has(field.key) && field.builtin !== false) return `custom field "${field.key}" must have builtin: false`; + + // ── abbr / unit / width ──────────────────────────────────────────────────── + if (field.abbr !== undefined && field.abbr !== null) { + if (typeof field.abbr !== 'string') return `abbr for "${field.key}" must be a string`; + if (field.abbr.length > 10) return `abbr for "${field.key}" must be ≤10 characters`; + } + if (field.unit !== undefined && field.unit !== null) { + if (typeof field.unit !== 'string') return `unit for "${field.key}" must be a string`; + if (field.unit.length > 10) return `unit for "${field.key}" must be ≤10 characters`; + } + if (field.width !== undefined && field.width !== null) { + if (!Number.isInteger(field.width) || field.width < 1 || field.width > 100) + return `width for "${field.key}" must be an integer between 1 and 100`; + } + + // ── tags ─────────────────────────────────────────────────────────────────── + if (field.tags !== undefined) { + if (!Array.isArray(field.tags)) return `tags for "${field.key}" must be an array`; + if (field.tags.length > 50) return `field "${field.key}" may have at most 50 tags`; + for (const tag of field.tags) { + if (typeof tag !== 'string' || !tag.trim()) return `tags for "${field.key}" must be non-empty strings`; + if (tag.length > 512) return `a tag in "${field.key}" exceeds 512 characters`; + } + } + + if (field.tagsDisabled !== undefined && typeof field.tagsDisabled !== 'boolean') + return `tagsDisabled for "${field.key}" must be a boolean`; } return null; } @@ -68,6 +95,7 @@ function ensureFieldIds(template) { return template.map((f) => ({ ...f, fieldId: f.fieldId || (f.builtin ? builtinById[f.key] : uuidv4()), + tags: f.tags ?? [], // always return tags array (empty if not yet set) })); } @@ -138,6 +166,63 @@ router.delete( } ); +// ── Subject-template validation ──────────────────────────────────────────────── + +function validateSubjectTemplate(template) { + if (!Array.isArray(template)) return 'subject_info_template must be an array'; + if (template.length > 50) return 'subject_info_template may have at most 50 fields'; + + const seenFieldIds = new Set(); + const seenKeys = new Set(); + + for (const field of template) { + if (!field.fieldId || !UUID_RE.test(field.fieldId)) + return `each field must have a valid UUID fieldId (got: ${field.fieldId})`; + if (seenFieldIds.has(field.fieldId)) return `duplicate fieldId "${field.fieldId}"`; + seenFieldIds.add(field.fieldId); + + if (!field.key || typeof field.key !== 'string') return 'each field must have a string key'; + if (!/^[a-z0-9_]+$/.test(field.key)) return `key "${field.key}" must be lowercase alphanumeric/underscore only`; + if (field.key.length > 64) return `key "${field.key}" must be ≤64 characters`; + if (seenKeys.has(field.key)) return `duplicate key "${field.key}"`; + seenKeys.add(field.key); + + if (!field.label || typeof field.label !== 'string' || !field.label.trim()) + return `field "${field.key}" must have a non-empty label`; + if (field.label.length > 128) return `label for "${field.key}" must be ≤128 characters`; + + if (!VALID_TYPES.has(field.type)) return `field "${field.key}" type must be "text" or "textarea"`; + if (typeof field.active !== 'boolean') return `field "${field.key}" must have a boolean active flag`; + if (field.builtin !== false) return `subject field "${field.key}" must have builtin: false`; + + if (field.abbr !== undefined && field.abbr !== null) { + if (typeof field.abbr !== 'string') return `abbr for "${field.key}" must be a string`; + if (field.abbr.length > 10) return `abbr for "${field.key}" must be ≤10 characters`; + } + if (field.unit !== undefined && field.unit !== null) { + if (typeof field.unit !== 'string') return `unit for "${field.key}" must be a string`; + if (field.unit.length > 10) return `unit for "${field.key}" must be ≤10 characters`; + } + if (field.width !== undefined && field.width !== null) { + if (!Number.isInteger(field.width) || field.width < 1 || field.width > 100) + return `width for "${field.key}" must be an integer between 1 and 100`; + } + + if (field.tags !== undefined) { + if (!Array.isArray(field.tags)) return `tags for "${field.key}" must be an array`; + if (field.tags.length > 50) return `field "${field.key}" may have at most 50 tags`; + for (const tag of field.tags) { + if (typeof tag !== 'string' || !tag.trim()) return `tags for "${field.key}" must be non-empty strings`; + if (tag.length > 512) return `a tag in "${field.key}" exceeds 512 characters`; + } + } + + if (field.tagsDisabled !== undefined && typeof field.tagsDisabled !== 'boolean') + return `tagsDisabled for "${field.key}" must be a boolean`; + } + return null; +} + // ── Template endpoints ───────────────────────────────────────────────────────── // GET /api/experiments/:id/template @@ -181,4 +266,106 @@ router.put('/:id/template', async (req, res, next) => { } catch (err) { next(err); } }); +// GET /api/experiments/:id/subject-template +router.get('/:id/subject-template', async (req, res, next) => { + try { + const experiment = await prisma.experiment.findUnique({ + where: { id: req.params.id }, + select: { id: true, subject_info_template: true }, + }); + if (!experiment) return res.status(404).json({ error: 'Experiment not found' }); + const tmpl = Array.isArray(experiment.subject_info_template) ? experiment.subject_info_template : []; + res.json(tmpl.map((f) => ({ ...f, tags: f.tags ?? [] }))); + } catch (err) { next(err); } +}); + +// PUT /api/experiments/:id/subject-template +router.put('/:id/subject-template', async (req, res, next) => { + try { + const experiment = await prisma.experiment.findUnique({ where: { id: req.params.id } }); + if (!experiment) return res.status(404).json({ error: 'Experiment not found' }); + + const template = req.body; + const validationError = validateSubjectTemplate(template); + if (validationError) return res.status(422).json({ error: validationError }); + + const updated = await prisma.experiment.update({ + where: { id: req.params.id }, + data: { subject_info_template: template }, + }); + + await prisma.auditLog.create({ + data: { + table_name: 'experiments', + record_id: experiment.id, + action: 'UPDATE', + changes: { before: { subject_info_template: experiment.subject_info_template }, after: { subject_info_template: template } }, + }, + }); + + res.json(template); + } catch (err) { next(err); } +}); + +// GET /api/experiments/:id/calendar?field= +// 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; diff --git a/backend/tests/calendar.test.js b/backend/tests/calendar.test.js new file mode 100644 index 0000000..2c3574b --- /dev/null +++ b/backend/tests/calendar.test.js @@ -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({}); + }); +}); diff --git a/docker-compose.yml b/docker-compose.yml index 9124540..1ed8da8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,8 @@ services: POSTGRES_USER: ${POSTGRES_USER:-expuser} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-exppassword} POSTGRES_DB: ${POSTGRES_DB:-experiments_db} + TZ: America/New_York + command: ["postgres", "-c", "timezone=America/New_York"] volumes: - postgres_data:/var/lib/postgresql/data # Port NOT exposed to host — only reachable by backend via internal Docker network @@ -34,6 +36,7 @@ services: NODE_ENV: production PORT: 3001 CORS_ORIGIN: "http://localhost:52867" + TZ: America/New_York # Non-root user set in Dockerfile; entrypoint runs migrations then starts server entrypoint: ["/bin/sh", "/app/docker-entrypoint.sh"] ports: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3408cd6..a535f56 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,7 +12,8 @@ "date-fns": "^3.6.0", "react": "^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": { "@babel/core": "^7.24.7", @@ -1757,7 +1758,6 @@ "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "dev": true, "engines": { "node": ">=6.9.0" } @@ -2832,6 +2832,60 @@ "@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": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -3598,6 +3652,14 @@ "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": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -3739,9 +3801,117 @@ "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "peer": true + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "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": { "version": "3.6.0", @@ -3775,6 +3945,11 @@ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "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": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -3917,6 +4092,15 @@ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "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": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", @@ -4156,6 +4340,11 @@ "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": { "version": "0.1.2", "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_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": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -4642,6 +4839,14 @@ "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": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", @@ -6282,6 +6487,11 @@ "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": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -6501,7 +6711,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -6950,6 +7159,21 @@ "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": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -7089,6 +7313,35 @@ "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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -7110,6 +7363,41 @@ "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": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -7758,6 +8046,11 @@ "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": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -7969,6 +8262,27 @@ "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": { "version": "4.5.14", "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.14.tgz", @@ -9407,8 +9721,7 @@ "@babel/runtime": { "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "dev": true + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==" }, "@babel/template": { "version": "7.28.6", @@ -10153,6 +10466,60 @@ "@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": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -10711,6 +11078,11 @@ "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": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -10820,9 +11192,84 @@ "csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "peer": true + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "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": { "version": "3.6.0", @@ -10844,6 +11291,11 @@ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "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": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -10946,6 +11398,15 @@ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "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": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", @@ -11116,6 +11577,11 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true }, + "eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, "exit": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", @@ -11135,6 +11601,11 @@ "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": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -11457,6 +11928,11 @@ "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": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", @@ -12669,6 +13145,11 @@ "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": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -12835,8 +13316,7 @@ "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" }, "object-hash": { "version": "3.0.0", @@ -13101,6 +13581,23 @@ "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": { "version": "2.1.0", "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-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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -13203,6 +13721,36 @@ "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": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -13674,6 +14222,11 @@ "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": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -13815,6 +14368,27 @@ "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": { "version": "4.5.14", "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.14.tgz", diff --git a/frontend/package.json b/frontend/package.json index 452ca84..bbf2455 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "axios": "^1.7.2", + "recharts": "^2.12.7", "date-fns": "^3.6.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index cdf60e3..7b88139 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,8 +1,10 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { Routes, Route, Link, useLocation } from 'react-router-dom'; +import { systemApi } from './api/client'; import Dashboard from './pages/Dashboard'; import ExperimentDetail from './pages/ExperimentDetail'; import AnimalDetail from './pages/AnimalDetail'; +import DailyStatusDetail from './pages/DailyStatusDetail'; function Navbar() { return ( @@ -33,18 +35,34 @@ function NotFound() { ); } +function Footer() { + const [dbTimezone, setDbTimezone] = useState(null); + useEffect(() => { + systemApi.info().then((d) => setDbTimezone(d.dbTimezone)).catch(() => {}); + }, []); + return ( +
+
+ Database timezone: {dbTimezone ?? '…'} +
+
+ ); +} + export default function App() { return ( -
+
-
+
} /> } /> } /> + } /> } />
+
); } diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 4845d8c..121963c 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -31,6 +31,10 @@ export const experimentsApi = { delete: (id) => api.delete(`/experiments/${id}`), getTemplate: (id) => api.get(`/experiments/${id}/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 ─────────────────────────────────────────────────────────────────── @@ -51,9 +55,23 @@ export const dailyStatusesApi = { create: (data) => api.post('/daily-statuses', 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}`), + updateAnalysisSummary: (id, data) => api.put(`/daily-statuses/${id}/analysis-summary`, data).then((r) => r.data), }; // ── Audit Logs ──────────────────────────────────────────────────────────────── export const auditLogsApi = { 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}`), +}; diff --git a/frontend/src/components/AnalysisCharts.jsx b/frontend/src/components/AnalysisCharts.jsx new file mode 100644 index 0000000..7bbc4b6 --- /dev/null +++ b/frontend/src/components/AnalysisCharts.jsx @@ -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 ( +
+

{header}

+ {payload.map((p) => ( +

+ {p.name}: {p.value != null ? `${p.value}${unit}` : '—'} +

+ ))} +
+ ); +} + +// ── 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 ( +
+ Tick every +
+ {STEP_OPTIONS.map((s) => ( + + ))} +
+
+ ); +} + +// ── 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 ( +
+
+

Session metrics over time

+
+ X axis: + +
+ +
+ + {/* Chart 1 — counts */} +
+

Attempts per session

+ + + + + + } /> + + {cats.map((cat, i) => ( + + ))} + + + +
+ + {/* Chart 2 — success rate */} +
+

Success rate (%)

+ + + + + `${v}%`} /> + } /> + + + +
+
+ ); +} + +// ── 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 ( +
+

{xLabel}: {label}

+ {payload.map((p) => { + const line = lines.find((l) => l.id === p.dataKey); + return p.value != null ? ( +

+ {line?.name ?? p.dataKey}{line?.group && line.group !== '—' ? ` (${line.group})` : ''}: {p.value}{unit} +

+ ) : null; + })} +
+ ); + } + + return ( +
+
+

Cross-subject metrics

+ + {/* X-axis field selector */} +
+ X axis: + +
+ + {/* Color-by subject info field */} + {groupableFields.length > 0 && ( +
+ Color by: + +
+ )} + +
+ + {!hasData && ( +

No sessions with saved metrics yet. Upload a CSV on a daily status page and click "Save metrics to daily status".

+ )} + + {hasData && ( + <> + {/* Chart 1 — counts */} +
+
+

Count per session — each line is one subject

+
+ {METRIC_OPTIONS.map((opt) => ( + + ))} +
+
+ + + + + + } /> + lines.find((l) => l.id === value)?.name ?? value} /> + {lines.map((l) => ( + + ))} + + +
+ + {/* Chart 2 — success rate */} +
+

Success rate (%) — each line is one subject

+ + + + + `${v}%`} /> + } /> + lines.find((l) => l.id === value)?.name ?? value} /> + {lines.map((l) => ( + + ))} + + +
+ + )} +
+ ); +} diff --git a/frontend/src/components/AuditLogSection.jsx b/frontend/src/components/AuditLogSection.jsx index 7cd2b14..785841c 100644 --- a/frontend/src/components/AuditLogSection.jsx +++ b/frontend/src/components/AuditLogSection.jsx @@ -36,7 +36,7 @@ const ACTION_BADGE = { 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 [total, setTotal] = useState(0); const [loading, setLoading] = useState(true); @@ -46,14 +46,14 @@ export default function AuditLogSection({ tableName, recordId }) { useEffect(() => { setLoading(true); auditLogsApi - .list({ tableName, recordId, limit: 50 }) + .list({ tableName, recordId, limit }) .then(({ logs, total }) => { setLogs(logs); setTotal(total); }) .catch((e) => setError(e.message)) .finally(() => setLoading(false)); - }, [tableName, recordId]); + }, [tableName, recordId, limit]); return (
@@ -76,7 +76,7 @@ export default function AuditLogSection({ tableName, recordId }) { {logs.length > 0 && (
- {logs.map((log) => ( + {logs.slice(0, limit).map((log) => (
); } diff --git a/frontend/src/components/CsvAnalysis.jsx b/frontend/src/components/CsvAnalysis.jsx new file mode 100644 index 0000000..6b6b730 --- /dev/null +++ b/frontend/src/components/CsvAnalysis.jsx @@ -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 ( + + {value} + {category && ( + + )} + + ); +} + +function CategoryBucket({ name, notes, colors, isOver, onDrop, onDragOver, onDragLeave, onRemoveNote, onClickAssign }) { + return ( +
{ 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'} + `} + > +

{name}

+
+ {notes.length === 0 && ( + Drop here + )} + {notes.map((note) => ( + + {note} + + + ))} +
+
+ ); +} + +// ── 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 ( +
+
+

CSV Analysis

+ {phase !== 'upload' && ( + + )} +
+ + {/* ── PHASE: upload ── */} + {phase === 'upload' && ( +
{ 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'} + `} + > + readFile(e.target.files[0])} /> +

+ {isDragOver ? 'Drop CSV file to upload' : 'Drop a CSV file here, or click to browse'} +

+

Columns will be read; data stays in your browser

+
+ )} + + {/* ── PHASE: columns ── */} + {phase === 'columns' && ( +
+

+ {fileName} loaded — {headers.length} columns detected. Select the columns to analyse. +

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ )} + + {/* ── PHASE: classify ── */} + {phase === 'classify' && ( +
+ + {/* Frame gap check results */} + {frameCheck && ( +
+

+ Frame check ({frameCol}): {frameCheck.consecutive ? `✓ all ${frameCheck.total} frames consecutive` : `⚠ issues found in ${frameCheck.total} frames`} +

+ {frameCheck.gaps.length > 0 && ( +
+ {frameCheck.gaps.length} gap{frameCheck.gaps.length !== 1 ? 's' : ''}: +
    + {frameCheck.gaps.map((g, i) => ( +
  • row {g.rowIndex + 1}: expected {g.expected}, got {g.actual} (jump +{g.jump})
  • + ))} +
+
+ )} + {frameCheck.duplicates.length > 0 && ( +
+ {frameCheck.duplicates.length} duplicate{frameCheck.duplicates.length !== 1 ? 's' : ''}: +
    + {frameCheck.duplicates.map((d, i) => ( +
  • row {d.rowIndex + 1}: frame {d.value}
  • + ))} +
+
+ )} +
+ )} + +

+ Found {uniqueNotes.length} unique note value{uniqueNotes.length !== 1 ? 's' : ''}. + {selectedNote && ( + + Selected: {selectedNote} — click a category to assign + + )} +

+ + {/* Unassigned chips */} + {unassignedNotes.length > 0 && ( +
+

Unassigned

+
+ {unassignedNotes.map(note => ( + setSelectedNote(prev => prev === note ? null : note)} + draggable onDragStart={() => setDraggedNote(note)} + /> + ))} +
+
+ )} + + {/* Category buckets */} +
+ {categories.map(cat => { + const colors = getCategoryColors(cat, customCategories); + const notesInCat = uniqueNotes.filter(n => classifications[n] === cat); + return ( + 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)} + /> + ); + })} +
+ + {/* Add new category */} +
+ {showNewCat ? ( + <> + 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" /> + + + + ) : ( + + )} +
+ + {/* Unclassified warning */} + {!allAssigned && uniqueNotes.length > 0 && ( +

+ {unassignedNotes.length} unassigned note value{unassignedNotes.length !== 1 ? 's' : ''} will be excluded from the analysis. +

+ )} + + +
+ )} + + {/* ── PHASE: results ── */} + {phase === 'results' && results && ( +
+
+

+ Analysed {results.totalNoteRows} note rows + · columns: {timestampCol} / {noteCol} +

+ {dailyStatusId && ( + + {saving && Saving…} + {!saving && !saveError && results && ✓ Saved} + {saveError && ⚠ Save failed} + + )} +
+ + {/* Category counts */} +
+

Category Counts

+ + + + + + + + + + {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 ( + + + + + + ); + })} + {Object.keys(results.categoryCounts).length === 0 && ( + + )} + +
CategoryNote breakdownTotal
+ + + {cat} + + {breakdown}{data.total}
No classified notes
+
+ + {/* Consecutive stats */} + {Object.keys(results.consecutiveStats).length > 0 && ( +
+

Consecutive Runs

+

+ How many times each category appeared in a row (run-length encoding of the category sequence, ordered by timestamp). +

+ + + + + + + + + + + + {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 ( + + + + + + + + ); + })} + +
CategoryTotal runsMax runAvg runRun lengths
+ + + {cat} + + {s.totalRuns}{s.maxRun}{s.avgRun.toFixed(2)}{runDisplay}
+
+ )} + + + + {/* Save metrics to daily status */} + {dailyStatusId && ( +
+
+ + {summaryError && {summaryError}} +
+ {!summaryPushed && ( +

+ Saves success / failure counts, total attempts, and success rate to this day's record for cross-day and cross-subject graphing. +

+ )} +
+ )} + +
+ + +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/DailyStatusForm.jsx b/frontend/src/components/DailyStatusForm.jsx index 4f08887..20bb2b6 100644 --- a/frontend/src/components/DailyStatusForm.jsx +++ b/frontend/src/components/DailyStatusForm.jsx @@ -1,12 +1,25 @@ import React, { useState, useEffect } from 'react'; import { format } from 'date-fns'; import Button from './ui/Button'; -import FormField, { Input, Textarea } from './ui/FormField'; +import { Input, Textarea } from './ui/FormField'; 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']); +// 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. * Custom fields are keyed by fieldId (stable UUID), not by key (display slug). */ function getValue(status, field) { @@ -15,14 +28,14 @@ function getValue(status, field) { 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'); // Active fields from template (date is always first and handled separately) const activeFields = template.filter((f) => f.active); 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) { vals[f.key] = getValue(existing, f); } @@ -33,6 +46,7 @@ export default function DailyStatusForm({ existing, animalId, template = [], onS const [errors, setErrors] = useState({}); const [apiError, setApiError] = useState(null); const [loading, setLoading] = useState(false); + const [savingTagFor, setSavingTagFor] = useState(null); // fieldId currently being saved useEffect(() => { 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 })); + 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() { const e = {}; if (!values.date) { @@ -53,38 +86,46 @@ export default function DailyStatusForm({ existing, animalId, template = [], onS return Object.keys(e).length === 0; } + async function doSave() { + const builtin = {}; + const custom = {}; + for (const f of activeFields) { + const val = values[f.key] || null; + if (f.builtin) { + builtin[f.key] = val; + } else { + custom[f.fieldId] = val; + } + } + const existingCustom = existing?.custom_fields ?? {}; + const mergedCustom = { ...existingCustom, ...custom }; + const payload = { date: values.date, ...builtin, custom_fields: mergedCustom }; + return existing + ? await dailyStatusesApi.update(existing.id, payload) + : await dailyStatusesApi.create({ ...payload, animal_id: animalId }); + } + async function handleSubmit(e) { e.preventDefault(); if (!validate()) return; setLoading(true); setApiError(null); try { - // Separate builtin values from custom_fields - const builtin = {}; - const custom = {}; - for (const f of activeFields) { - const val = values[f.key] || null; - if (f.builtin) { - builtin[f.key] = val; - } 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; - } - } + onSuccess(await doSave()); + } catch (err) { + setApiError(err); + } finally { + setLoading(false); + } + } - // 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 mergedCustom = { ...existingCustom, ...custom }; - - const payload = { date: values.date, ...builtin, custom_fields: mergedCustom }; - - const result = existing - ? await dailyStatusesApi.update(existing.id, payload) - : await dailyStatusesApi.create({ ...payload, animal_id: animalId }); - - onSuccess(result); + async function handleNavigate(direction) { + if (!validate()) return; + setLoading(true); + setApiError(null); + try { + const result = await doSave(); + onNavigate(direction, result); } catch (err) { setApiError(err); } finally { @@ -93,36 +134,62 @@ export default function DailyStatusForm({ existing, animalId, template = [], onS } return ( -
+ {apiError && ( setApiError(null)} /> )} - {/* Date — always present */} - - - +
+ {/* Date — always full width */} +
+ +
+ + {errors.date &&

{errors.date}

} +
+
- {/* Dynamic fields from template */} - {activeFields.map((field) => ( - - {field.type === 'textarea' ? ( -