Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5314071c6b | |||
| 86feec2e77 | |||
| 4f3fe64cad | |||
| 33ee191966 | |||
| 6f23f83bc8 | |||
| 39a5a4ec0c | |||
| 8510903771 | |||
| 3e52117c1a | |||
| 8153777951 | |||
| 9b3d52d89b | |||
| e65de8892a | |||
| 8ab270a9c9 | |||
| 958666580f | |||
| 600323f40a | |||
| 7d112de0d7 | |||
| a411d59305 | |||
| cd4e213d6f | |||
| 900759074d | |||
| 57446d5b64 | |||
| ea1a7f39f2 | |||
| 0bf3fc479b | |||
| ea76cf9cd2 | |||
| dfe3938afc | |||
| b5578f6e24 | |||
| 3f8febfda3 | |||
| 523cf1614e | |||
| 9b55b578f2 | |||
| 2d3b7c4607 | |||
| 740a884efa | |||
| b0c6217cb4 | |||
| 9a9b5dfae8 | |||
| 4b82df514f | |||
| e9511bdac3 | |||
| 2dd4a329ee | |||
| 8877747a63 | |||
| c768b5829a | |||
| 839d05f449 | |||
| 7b34c2fa52 | |||
| 0f7f6fbbe3 | |||
| d77b6c1528 | |||
| 3095628479 | |||
| 1b0f083cc5 | |||
| 75a343d617 | |||
| 39c01c567c | |||
| ef17ee0642 | |||
| ceaa3be200 | |||
| b9b8a4ef1d | |||
| 0b83795665 | |||
| 6a6e6b310a | |||
| fe4e89f497 | |||
| 1ae55591f4 | |||
| 7b023fb8a3 | |||
| cdff6732b0 | |||
| 69ce5fbeab | |||
| 2ed69aa6d6 | |||
| ee2cf19b74 | |||
| d125d2e365 | |||
| c0d0b8e6ba | |||
| 5c0703c271 | |||
| d2c8d69718 | |||
| 52d5fca5d1 | |||
| 5cd6d2a8a3 | |||
| b73768ee29 | |||
| 66e79c1780 | |||
| daf36908ef | |||
| dc46af8d31 | |||
| 6dfb427f91 | |||
| c8f0c5f298 | |||
| 6ecc6dc19a | |||
| 5f3ad1d408 | |||
| b888c1b76d | |||
| 7439a1a101 | |||
| 9b51250d8e | |||
| 8b40856205 | |||
| d764bc66fb | |||
| 4a048fda88 | |||
| 755caca09e | |||
| fe23085e0f | |||
| 78fe3f68cc | |||
| 9535f86970 | |||
| 09a853e28b | |||
| b7374777d3 | |||
| 1305e53f96 | |||
| 86a56427b7 | |||
| 80fb1c6e27 | |||
| b03566e658 | |||
| 9b3c883bf0 | |||
| 5460a93217 | |||
| 5874ed8374 | |||
| c359cb4bb9 |
@@ -0,0 +1,158 @@
|
||||
# tDCS reaching study — GLM methods
|
||||
|
||||
This document explains the statistics in `tdcs_glm.py`: the models, the exact
|
||||
formulas, how each subject's progress is accounted for, and the caveats.
|
||||
|
||||
## The question
|
||||
|
||||
Two **known** conditions anchor the performance scale and differ from each other:
|
||||
|
||||
- **H2 (anchor check):** `Electrode-Box-B2` performs **better** than `Electrode-Box-A2`.
|
||||
|
||||
Two **unknown** conditions are then **classified** against those anchors — for
|
||||
each of `Electrode-Box-A` and `Right-Electrode`, is it **A2-like** or **B2-like**?
|
||||
We do *not* assume either belongs to B2; each unknown is compared to *both*
|
||||
anchors, and the anchor it cannot be distinguished from is its likely class.
|
||||
|
||||
There is also a **merged-assumption** mode (`--merge`): assume the two unknowns
|
||||
resolve as `Right-Electrode == Box-B2` and `Electrode-Box-A == Box-A2`, fold them
|
||||
into the anchors, and re-estimate everything with 4 subjects per group.
|
||||
|
||||
## Data and outcome
|
||||
|
||||
- **Outcome:** `success` = successful reaches in a session
|
||||
(`analysis_summary.counts.Success`), a non-negative **count**.
|
||||
- **Attempts:** `total` = reach attempts in the session (`analysis_summary.total`);
|
||||
used as the denominator for the rate model. `success_rate == success / total`.
|
||||
- **Time:** `day` = the "# Days Reach" field (training day, 0..26). Analyses use
|
||||
days ≥ 0 (pre-training negative days and zero-attempt sessions are excluded;
|
||||
zero-attempt sessions are undefined for the rate model).
|
||||
- **Groups (subjects):** Naive (4), Box-A2 (3), Box-B2 (3), Box-A (1),
|
||||
Right-Electrode (1). Under `--merge`: Box-A2 (4), Box-B2 (4), Naive (4).
|
||||
|
||||
**Provenance:** `tdcs_reach_data.csv` was reconstructed from the experiment
|
||||
database and verified cell-by-cell against the exported matrix (71/71
|
||||
unambiguous cells on days 0–5 matched exactly).
|
||||
|
||||
## The three models
|
||||
|
||||
All models use `Electrode-Box-B2` as the **reference** group, so each group term
|
||||
is that group's contrast *versus Box-B2*. `day_c` is the centered training day and
|
||||
`day_c2 = day_c²` captures the rise-then-plateau of the learning curve.
|
||||
|
||||
### (A) Level — count (primary)
|
||||
|
||||
Poisson GEE on the success counts, clustered by subject:
|
||||
|
||||
```
|
||||
success ~ C(group, Treatment('Electrode-Box-B2')) + day_c + day_c2
|
||||
family = Poisson (log link)
|
||||
groups = subject # repeated-measures cluster
|
||||
cov_struct = Exchangeable # working within-subject correlation
|
||||
SE = robust (sandwich)
|
||||
```
|
||||
|
||||
`exp(coef)` for a group term is an **incidence-rate ratio (IRR)**: expected
|
||||
successes relative to Box-B2.
|
||||
|
||||
### (B) Level — rate
|
||||
|
||||
Binomial GLM on successes-out-of-attempts, with cluster-robust SEs by subject:
|
||||
|
||||
```
|
||||
cbind(success, total - success) ~ C(group, Treatment('Electrode-Box-B2')) + day_c + day_c2
|
||||
family = Binomial (logit link)
|
||||
cov_type = cluster (groups = subject)
|
||||
```
|
||||
|
||||
`exp(coef)` is an **odds ratio** for a successful reach relative to Box-B2. This
|
||||
controls for differing numbers of attempts, so it answers "who is more accurate
|
||||
per attempt?" rather than "who attempts more?"
|
||||
|
||||
### (C) Learning rate
|
||||
|
||||
Poisson GEE with a **group × day** interaction, to ask whether groups improve at
|
||||
different *rates* (not just different levels):
|
||||
|
||||
```
|
||||
success ~ C(group, Treatment('Electrode-Box-B2')) * day_c + day_c2
|
||||
```
|
||||
|
||||
Each `group[T.X]:day_c` term is the difference in log-slope versus Box-B2; a joint
|
||||
Wald test asks whether *any* group's slope differs. Large p ⇒ parallel learning.
|
||||
|
||||
### Anchors-only model
|
||||
|
||||
The A2-vs-B2 comparison, refit on **just the two anchor groups** so nothing else
|
||||
influences the shared day terms or the dispersion/correlation nuisance:
|
||||
|
||||
```
|
||||
# subset to {Box-A2, Box-B2}
|
||||
success ~ C(group, Treatment('Electrode-Box-B2')) + day_c + day_c2 # count
|
||||
cbind(success, total-success) ~ C(group, ...) + day_c + day_c2 # rate
|
||||
```
|
||||
|
||||
With B2 as reference the single group term is the A2-vs-B2 effect; H2 is a
|
||||
one-sided test that this coefficient is below zero.
|
||||
|
||||
## How each subject's progress is accounted for
|
||||
|
||||
Two distinct pieces:
|
||||
|
||||
1. **Progress over training** — the `day_c + day_c2` fixed terms model the average
|
||||
learning curve, so groups are compared at comparable points in training rather
|
||||
than being confounded by *when* each was measured.
|
||||
|
||||
2. **Repeated measures / individual baselines** — each subject contributes many
|
||||
correlated sessions and has its own baseline. Handled two ways:
|
||||
|
||||
- **Primary (GEE):** subject is the cluster; an exchangeable working
|
||||
correlation plus robust (sandwich) SEs give *population-average* group
|
||||
effects whose inference is valid under within-subject correlation and
|
||||
Poisson overdispersion.
|
||||
|
||||
- **Sensitivity (mixed model):** a Poisson model with a **per-subject random
|
||||
intercept** (`(1 | subject)`) so each animal gets its own baseline level;
|
||||
the group effects are estimated after allowing for that individual variation.
|
||||
statsmodels has no frequentist Poisson GLMM, so this is the **MAP/Laplace**
|
||||
fit (`fit_vb` diverges on these large counts). Its p-values are approximate —
|
||||
read it as a direction/magnitude check that should agree with GEE.
|
||||
|
||||
## Classification logic (unknowns)
|
||||
|
||||
For each unknown group, `diff_contrast` builds a linear contrast of that group
|
||||
against **each anchor** (both coded vs the B2 reference) and tests it:
|
||||
|
||||
- indistinguishable from B2 **and** different from A2 → **B2-like**
|
||||
- indistinguishable from A2 **and** different from B2 → **A2-like**
|
||||
- indistinguishable from both → **ambiguous** (report the numerically nearer one)
|
||||
- different from both → **unlike both**
|
||||
|
||||
## Caveats
|
||||
|
||||
- **Tiny groups.** Box-A2/B2 have 3 subjects, Naive 4, and each unknown has
|
||||
**n = 1 subject**. Classifying a one-subject condition is weak: "matches
|
||||
anchor X" means "not statistically distinguishable from X," **not** proof of
|
||||
equivalence.
|
||||
- **Single-cluster fragility.** Cluster-robust/GEE inference with one cluster in a
|
||||
group can produce artificially small SEs — most visibly the learning-rate
|
||||
interaction for the n=1 groups; do not read those p-values literally.
|
||||
- **Mixed-model confounding.** With a per-subject random intercept, a group made
|
||||
of one subject is partly confounded with that subject's random intercept, so its
|
||||
fixed effect is shrunk.
|
||||
- **Rate vs count.** "Success" alone is a count; the rate model (success/attempts)
|
||||
is the fairer accuracy comparison when attempt counts differ.
|
||||
|
||||
## Files and usage
|
||||
|
||||
- `tdcs_glm.py` — the analysis (run it directly).
|
||||
- `tdcs_reach_data.csv` — the verified long-format data (`subject, group, day, success, total`).
|
||||
- `tdcs_learning_curves*.png` — per-group learning curves (count and rate panels).
|
||||
|
||||
```
|
||||
python3 analysis/tdcs_glm.py # all training days, 5 groups
|
||||
python3 analysis/tdcs_glm.py --max-day 10 # restrict to days 0–10
|
||||
python3 analysis/tdcs_glm.py --merge # assume Right==B2 and Box-A==A2 (3 groups)
|
||||
```
|
||||
|
||||
Requires: pandas, numpy, scipy, statsmodels, matplotlib.
|
||||
Binary file not shown.
@@ -0,0 +1,440 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
tDCS reaching study — GLM hypothesis tests.
|
||||
|
||||
QUESTION
|
||||
Two KNOWN conditions anchor the scale and differ from each other:
|
||||
H2 (anchor check): Electrode-Box-B2 performs BETTER than Electrode-Box-A2.
|
||||
Two UNKNOWN conditions then get CLASSIFIED against those anchors:
|
||||
For "Electrode-Box-A" and for "Right-Electrode", is each one
|
||||
A2-like or B2-like? (We do not assume either belongs to B2.)
|
||||
Each unknown is compared to BOTH anchors; a group it cannot be
|
||||
distinguished from is its likely class.
|
||||
|
||||
Each comparison is examined through THREE complementary GLMs:
|
||||
|
||||
(A) LEVEL / count Poisson GEE on `success` (successful reaches per
|
||||
session), adjusting for training day. Answers
|
||||
"who makes more successful reaches overall?"
|
||||
|
||||
(B) LEVEL / rate Binomial GLM on success / attempts
|
||||
(`success` out of `total`), cluster-robust.
|
||||
Controls for differing numbers of attempts —
|
||||
"who is more accurate per attempt?"
|
||||
|
||||
(C) LEARNING RATE Poisson GEE with a group x day interaction.
|
||||
Answers "do the groups improve at different
|
||||
RATES?" (slope of the learning curve).
|
||||
|
||||
All models adjust for time with day (and day^2 for the plateau) and account
|
||||
for repeated measures on each subject via GEE (subject = cluster,
|
||||
exchangeable correlation, robust SE) or cluster-robust standard errors.
|
||||
Group is coded with Electrode-Box-B2 as the REFERENCE, so H1 and H2 read
|
||||
directly off the group terms:
|
||||
Right-Electrode term -> H1 (expect ~0 / non-significant)
|
||||
Electrode-Box-A2 term -> H2 (expect < 0: B2 above A2)
|
||||
exp(coef) is an incidence-rate ratio (count model) or odds ratio (rate
|
||||
model) relative to Box-B2.
|
||||
|
||||
CAVEATS
|
||||
* Tiny groups: Box-A2 n=3, Box-B2 n=3, Naive n=4, and each UNKNOWN
|
||||
(Electrode-Box-A, Right-Electrode) has only n=1 SUBJECT. Classifying a
|
||||
1-subject condition is weak: "matches anchor X" means "not statistically
|
||||
distinguishable from X", NOT proof of equivalence, and cluster-robust
|
||||
inference with a single cluster in a group is fragile (can show
|
||||
artificially small SEs, especially in the learning-rate interaction).
|
||||
|
||||
DATA PROVENANCE
|
||||
analysis/tdcs_reach_data.csv was reconstructed from the experiment
|
||||
database (experiment "tDCS", outcome = analysis_summary.counts.Success,
|
||||
attempts = analysis_summary.total, x-axis = the "# Days Reach" field) and
|
||||
verified cell-by-cell against the exported matrix (71/71 unambiguous
|
||||
cells on days 0-5 matched exactly).
|
||||
|
||||
USAGE
|
||||
python3 analysis/tdcs_glm.py
|
||||
Requires: pandas, numpy, scipy, statsmodels, matplotlib.
|
||||
Writes: analysis/tdcs_learning_curves.png
|
||||
"""
|
||||
import os
|
||||
import argparse
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import patsy
|
||||
import statsmodels.api as sm
|
||||
import statsmodels.formula.api as smf
|
||||
from scipy import stats
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
REF = "Electrode-Box-B2"
|
||||
# Two KNOWN anchor conditions that differ (Box-B2 > Box-A2, see H2).
|
||||
ANCHOR_LOW = "Electrode-Box-A2"
|
||||
ANCHOR_HIGH = "Electrode-Box-B2"
|
||||
# Two UNKNOWN conditions to classify: is each one A2-like or B2-like?
|
||||
CANON_UNKNOWNS = ["Electrode-Box-A", "Right-Electrode"]
|
||||
ALL_GROUPS = ["Naive", ANCHOR_LOW, ANCHOR_HIGH] + CANON_UNKNOWNS
|
||||
# The active group config is set at runtime (after any --merge/--assign remap).
|
||||
UNKNOWNS = list(CANON_UNKNOWNS)
|
||||
MAIN = list(ALL_GROUPS)
|
||||
OTHERS = [g for g in MAIN if g != REF]
|
||||
# --merge preset: assume each unknown resolves into an anchor.
|
||||
MERGE_MAP = {"Electrode-Box-A": ANCHOR_LOW, "Right-Electrode": ANCHOR_HIGH}
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
DATA = os.path.join(HERE, "tdcs_reach_data.csv")
|
||||
PLOT = os.path.join(HERE, "tdcs_learning_curves.png")
|
||||
RHS = f"C(group, Treatment('{REF}')) + day_c + day_c2"
|
||||
|
||||
|
||||
def gterm(name):
|
||||
"""statsmodels/patsy column name for a group level (vs the reference)."""
|
||||
return f"C(group, Treatment('{REF}'))[T.{name}]"
|
||||
|
||||
|
||||
def xterm(name):
|
||||
"""Interaction column name (group level x day slope)."""
|
||||
return f"C(group, Treatment('{REF}'))[T.{name}]:day_c"
|
||||
|
||||
|
||||
def load(max_day=None, mapping=None):
|
||||
df = pd.read_csv(DATA)
|
||||
if mapping: # remap unknown -> target group
|
||||
df["group"] = df["group"].map(lambda g: mapping.get(g, g))
|
||||
df = df[df["group"].isin(ALL_GROUPS)].copy() # keep canonical groups
|
||||
df = df[df["day"] >= 0].copy() # training days only (day 0..)
|
||||
if max_day is not None:
|
||||
df = df[df["day"] <= max_day].copy() # restrict the analysis window
|
||||
df["day_c"] = df["day"] - df["day"].mean() # center day
|
||||
df["day_c2"] = df["day_c"] ** 2
|
||||
present = [g for g in ALL_GROUPS if g != REF and g in set(df["group"])]
|
||||
df["group"] = pd.Categorical(df["group"], categories=[REF] + present)
|
||||
return df
|
||||
|
||||
|
||||
def contrast(res, tname):
|
||||
"""coef, robust SE, p, effect=exp(coef) and its 95% CI for one term."""
|
||||
ci = res.conf_int()
|
||||
lo, hi = ci.loc[tname]
|
||||
return dict(coef=res.params[tname], se=res.bse[tname], p=res.pvalues[tname],
|
||||
eff=np.exp(res.params[tname]), lo=np.exp(lo), hi=np.exp(hi))
|
||||
|
||||
|
||||
def one_sided_p_below(c):
|
||||
"""One-sided p for H: coef < 0 (i.e. Box-B2 above the compared group)."""
|
||||
return stats.norm.cdf(c["coef"] / c["se"])
|
||||
|
||||
|
||||
def diff_contrast(res, a, b):
|
||||
"""Compare group `a` vs group `b` (both coded relative to REF) via a linear
|
||||
contrast. Returns coef, robust SE, p, effect=exp(coef)=ratio a/b, and CI.
|
||||
Works for any pair, including b == REF (then it is just the `a` term)."""
|
||||
names = list(res.params.index)
|
||||
v = np.zeros(len(names))
|
||||
if a != REF:
|
||||
v[names.index(gterm(a))] += 1.0
|
||||
if b != REF:
|
||||
v[names.index(gterm(b))] -= 1.0
|
||||
tt = res.t_test(v)
|
||||
coef = float(np.ravel(tt.effect)[0])
|
||||
se = float(np.ravel(tt.sd)[0])
|
||||
p = float(np.ravel(tt.pvalue)[0])
|
||||
lo, hi = np.ravel(tt.conf_int())[:2]
|
||||
return dict(coef=coef, se=se, p=p, eff=np.exp(coef),
|
||||
lo=np.exp(lo), hi=np.exp(hi))
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- models
|
||||
def fit_count_level(df):
|
||||
"""(A) Poisson GEE on success counts; group main effects vs Box-B2."""
|
||||
return smf.gee(f"success ~ {RHS}", groups="subject", data=df,
|
||||
family=sm.families.Poisson(),
|
||||
cov_struct=sm.cov_struct.Exchangeable()).fit()
|
||||
|
||||
|
||||
def fit_rate_level(df):
|
||||
"""(B) Binomial GLM on success/attempts with cluster-robust SE.
|
||||
Zero-attempt sessions (total==0) carry no rate information and are dropped."""
|
||||
d = df[df["total"] > 0].copy()
|
||||
dropped = len(df) - len(d)
|
||||
if dropped:
|
||||
print(f"[rate model] dropped {dropped} zero-attempt session(s) (undefined rate)")
|
||||
X = patsy.dmatrix(RHS, d, return_type="dataframe")
|
||||
endog = np.column_stack([d["success"].values,
|
||||
(d["total"] - d["success"]).values])
|
||||
return sm.GLM(endog, X, family=sm.families.Binomial()).fit(
|
||||
cov_type="cluster", cov_kwds={"groups": d["subject"].values})
|
||||
|
||||
|
||||
def fit_learning_rate(df):
|
||||
"""(C) Poisson GEE with group x day interaction (slope differences)."""
|
||||
return smf.gee(
|
||||
f"success ~ C(group, Treatment('{REF}')) * day_c + day_c2",
|
||||
groups="subject", data=df, family=sm.families.Poisson(),
|
||||
cov_struct=sm.cov_struct.Exchangeable()).fit()
|
||||
|
||||
|
||||
def fit_mixed_count(df):
|
||||
"""Subject random-intercept Poisson mixed model (MAP / Laplace).
|
||||
|
||||
Adds a per-subject random intercept so each subject has its own baseline
|
||||
level; the group fixed effects are then estimated after allowing for that
|
||||
individual variation. A subject-specific complement to the population-
|
||||
average GEE. statsmodels has no frequentist Poisson GLMM, so this is the
|
||||
posterior-mode (MAP) fit with a Laplace covariance for the fixed effects
|
||||
(fit_vb failed to converge on these large counts; MAP is stable)."""
|
||||
import warnings
|
||||
from statsmodels.genmod.bayes_mixed_glm import PoissonBayesMixedGLM
|
||||
vc = {"subject": "0 + C(subject)"} # random intercept per subject
|
||||
model = PoissonBayesMixedGLM.from_formula(f"success ~ {RHS}", vc, df)
|
||||
with warnings.catch_warnings():
|
||||
# MAP stops at |gradient|~5e-5 (effectively converged); silence the
|
||||
# over-strict "did not converge" notice.
|
||||
warnings.filterwarnings("ignore", message="Laplace fitting did not converge")
|
||||
return model.fit_map()
|
||||
|
||||
|
||||
def mixed_contrast(res, tname):
|
||||
"""coef, SD, approx p and IRR + 95% interval from a BayesMixedGLM fit."""
|
||||
names = list(res.model.exog_names)
|
||||
i = names.index(tname)
|
||||
mean, sd = float(res.fe_mean[i]), float(res.fe_sd[i])
|
||||
p = 2.0 * stats.norm.sf(abs(mean / sd))
|
||||
return dict(coef=mean, se=sd, p=p, eff=np.exp(mean),
|
||||
lo=np.exp(mean - 1.96 * sd), hi=np.exp(mean + 1.96 * sd))
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- reports
|
||||
def describe(df):
|
||||
print("=" * 78)
|
||||
print("DESCRIPTIVES")
|
||||
print("=" * 78)
|
||||
grp = df.groupby("group", observed=True)
|
||||
tbl = pd.DataFrame({
|
||||
"n_subj": grp["subject"].nunique(),
|
||||
"n_sessions": grp.size(),
|
||||
"mean_success": grp["success"].mean().round(1),
|
||||
"mean_rate": (grp["success"].sum() / grp["total"].sum()).round(3),
|
||||
"max_day": grp["day"].max(),
|
||||
})
|
||||
print(tbl.to_string())
|
||||
|
||||
|
||||
def report_level(title, res, effect_label):
|
||||
print("\n" + "=" * 78)
|
||||
print(title)
|
||||
print("=" * 78)
|
||||
print(f"Effect vs {REF} ({effect_label} relative to Box-B2):")
|
||||
h = f"{'group':20s} {'effect':>7s} {'95% CI':>16s} {'coef':>8s} {'SE':>7s} {'p':>9s}"
|
||||
print(h)
|
||||
print("-" * len(h))
|
||||
out = {}
|
||||
for g in OTHERS:
|
||||
c = contrast(res, gterm(g))
|
||||
out[g] = c
|
||||
ci = f"[{c['lo']:.2f}, {c['hi']:.2f}]"
|
||||
print(f"{g:20s} {c['eff']:7.3f} {ci:>16s} {c['coef']:8.3f} {c['se']:7.3f} {c['p']:9.4f}")
|
||||
return out
|
||||
|
||||
|
||||
def report_learning(res):
|
||||
print("\n" + "=" * 78)
|
||||
print("(C) LEARNING RATE — Poisson GEE, group x day interaction")
|
||||
print("=" * 78)
|
||||
base = res.params["day_c"]
|
||||
print(f"Box-B2 learning slope: {np.exp(base):.3f}x successes per training day "
|
||||
f"(baseline).")
|
||||
print("Slope DIFFERENCE vs Box-B2 (exp(coef) = per-day multiplier on the rate ratio):")
|
||||
h = f"{'group':20s} {'slopeΔ/day':>10s} {'coef':>8s} {'SE':>7s} {'p':>9s}"
|
||||
print(h)
|
||||
print("-" * len(h))
|
||||
for g in OTHERS:
|
||||
c = contrast(res, xterm(g))
|
||||
print(f"{g:20s} {c['eff']:10.3f} {c['coef']:8.3f} {c['se']:7.3f} {c['p']:9.4f}")
|
||||
# Joint Wald test: do ANY groups differ from Box-B2 in learning rate?
|
||||
names = list(res.params.index)
|
||||
idx = [names.index(xterm(g)) for g in OTHERS]
|
||||
R = np.zeros((len(idx), len(names)))
|
||||
for i, j in enumerate(idx):
|
||||
R[i, j] = 1.0
|
||||
jt = res.wald_test(R, scalar=True)
|
||||
print(f"\nJoint test (all group x day interactions = 0): "
|
||||
f"chi2={float(jt.statistic):.2f}, df={len(idx)}, p={float(jt.pvalue):.4f}")
|
||||
print(" -> small p = groups improve at DIFFERENT rates; large p = parallel learning.")
|
||||
|
||||
|
||||
def report_anchor_only(df):
|
||||
"""A2-vs-B2 ONLY: refit on just the two anchors so nothing else influences
|
||||
the shared day terms / dispersion. B2 is the reference, so the single group
|
||||
term is the A2-vs-B2 comparison."""
|
||||
print("\n" + "=" * 78)
|
||||
print("ANCHORS ONLY — Box-A2 vs Box-B2 (two-group models)")
|
||||
print("=" * 78)
|
||||
d = df[df["group"].isin([ANCHOR_LOW, ANCHOR_HIGH])].copy()
|
||||
d["day_c"] = d["day"] - d["day"].mean()
|
||||
d["day_c2"] = d["day_c"] ** 2
|
||||
d["group"] = pd.Categorical(d["group"], categories=[ANCHOR_HIGH, ANCHOR_LOW])
|
||||
n_sub = d.groupby("group", observed=True)["subject"].nunique().to_dict()
|
||||
print(f"subjects: Box-B2={n_sub.get(ANCHOR_HIGH)}, Box-A2={n_sub.get(ANCHOR_LOW)}"
|
||||
f" sessions: {len(d)}")
|
||||
print(f"formula: success ~ C(group, Treatment('{REF}')) + day_c + day_c2")
|
||||
for label, res in [("count/level (Poisson GEE)", fit_count_level(d)),
|
||||
("rate/level (Binomial cluster-robust)", fit_rate_level(d))]:
|
||||
c = contrast(res, gterm(ANCHOR_LOW))
|
||||
p1 = one_sided_p_below(c)
|
||||
tag = "SUPPORTED" if (c["coef"] < 0 and p1 < 0.05) else "not supported"
|
||||
print(f" [{label}] A2/B2 = {c['eff']:.2f} [{c['lo']:.2f}, {c['hi']:.2f}] "
|
||||
f"=> B2 = {1.0 / c['eff']:.2f}x A2, one-sided p={p1:.4f} -> H2 {tag}")
|
||||
|
||||
|
||||
def report_mixed(df):
|
||||
"""Subject random-intercept Poisson mixed model (sensitivity vs GEE)."""
|
||||
print("\n" + "=" * 78)
|
||||
print("SENSITIVITY — Poisson MIXED model, per-subject random intercept (MAP/Laplace)")
|
||||
print("=" * 78)
|
||||
try:
|
||||
res = fit_mixed_count(df)
|
||||
except Exception as exc: # VB can be finicky; degrade gracefully
|
||||
print(f" (mixed model skipped: {type(exc).__name__}: {exc})")
|
||||
return
|
||||
print("Group effect vs Box-B2 (IRR; each subject given its own baseline):")
|
||||
h = f"{'group':20s} {'IRR':>7s} {'~95% CI':>16s} {'mean':>8s} {'SD':>7s} {'~p':>9s}"
|
||||
print(h)
|
||||
print("-" * len(h))
|
||||
for g in OTHERS:
|
||||
c = mixed_contrast(res, gterm(g))
|
||||
ci = f"[{c['lo']:.2f}, {c['hi']:.2f}]"
|
||||
print(f"{g:20s} {c['eff']:7.3f} {ci:>16s} {c['coef']:8.3f} {c['se']:7.3f} {c['p']:9.4f}")
|
||||
print(f" subject random-intercept SD = {float(np.exp(res.vcp_mean[0])):.3f} "
|
||||
"(log scale); Laplace SEs, treat p-values as approximate.")
|
||||
print(" Compare directions/magnitudes with the GEE table (they should agree).")
|
||||
print(" Single-subject groups (Box-A, Right-Electrode) are partly confounded")
|
||||
print(" with their own random intercept here, so their effects are shrunk.")
|
||||
|
||||
|
||||
def plot_curves(df, plot_path):
|
||||
fig, ax = plt.subplots(1, 2, figsize=(12, 4.8))
|
||||
for g in MAIN:
|
||||
s = df[df["group"] == g]
|
||||
d = s.groupby("day").apply(
|
||||
lambda x: pd.Series({"succ": x["success"].mean(),
|
||||
"rate": x["success"].sum() / x["total"].sum()}),
|
||||
include_groups=False)
|
||||
ax[0].plot(d.index, d["succ"], marker="o", ms=3, label=g)
|
||||
ax[1].plot(d.index, d["rate"], marker="o", ms=3, label=g)
|
||||
ax[0].set(xlabel="# Days Reach (training day)", ylabel="mean successful reaches",
|
||||
title="(A) Success count learning curves")
|
||||
ax[1].set(xlabel="# Days Reach (training day)", ylabel="success rate (success/attempts)",
|
||||
title="(B) Success-rate learning curves")
|
||||
for a in ax:
|
||||
a.legend(fontsize=8)
|
||||
a.grid(alpha=0.3)
|
||||
fig.tight_layout()
|
||||
fig.savefig(plot_path, dpi=120)
|
||||
print(f"\nSaved learning-curve plot -> {plot_path}")
|
||||
|
||||
|
||||
def classify_one(res, unknown):
|
||||
"""Compare an unknown group against BOTH anchors; return a label + detail."""
|
||||
vlo = diff_contrast(res, unknown, ANCHOR_LOW) # unknown vs Box-A2
|
||||
vhi = diff_contrast(res, unknown, ANCHOR_HIGH) # unknown vs Box-B2
|
||||
like_lo = vlo["p"] >= 0.05 # indistinguishable from Box-A2
|
||||
like_hi = vhi["p"] >= 0.05 # indistinguishable from Box-B2
|
||||
if like_hi and not like_lo:
|
||||
verdict = "B2-like (differs from A2, matches B2)"
|
||||
elif like_lo and not like_hi:
|
||||
verdict = "A2-like (differs from B2, matches A2)"
|
||||
elif like_lo and like_hi:
|
||||
# tie: point toward whichever ratio is closer to 1 on the log scale
|
||||
nearer = ANCHOR_HIGH if abs(vhi["coef"]) < abs(vlo["coef"]) else ANCHOR_LOW
|
||||
verdict = f"AMBIGUOUS (matches both; numerically nearer {nearer})"
|
||||
else:
|
||||
verdict = "UNLIKE BOTH (differs from A2 and B2)"
|
||||
return vlo, vhi, verdict
|
||||
|
||||
|
||||
def verdicts(count, rate):
|
||||
print("\n" + "=" * 78)
|
||||
print("VERDICTS")
|
||||
print("=" * 78)
|
||||
|
||||
print("\nAnchor check — H2: Box-B2 BETTER than Box-A2 (the two anchors must differ)")
|
||||
for label, res in [("count/level", count), ("rate/level ", rate)]:
|
||||
c = contrast(res, gterm(ANCHOR_LOW))
|
||||
p1 = one_sided_p_below(c)
|
||||
tag = "SUPPORTED" if (c["coef"] < 0 and p1 < 0.05) else "not supported"
|
||||
print(f" [{label}] Box-B2 = {1.0 / c['eff']:.2f}x Box-A2 "
|
||||
f"(one-sided p={p1:.4f}) -> {tag}")
|
||||
|
||||
if not UNKNOWNS:
|
||||
print("\n(No unknown groups — they were merged into the anchors; "
|
||||
"only the H2 anchor contrast applies.)")
|
||||
return
|
||||
print("\nClassification — is each UNKNOWN condition A2-like or B2-like?")
|
||||
print("(ratio >1 = above that anchor; p = differs from that anchor)")
|
||||
for u in UNKNOWNS:
|
||||
print(f"\n {u}:")
|
||||
for label, res in [("count/level", count), ("rate/level ", rate)]:
|
||||
vlo, vhi, verdict = classify_one(res, u)
|
||||
print(f" [{label}] vs Box-A2: {vlo['eff']:.2f}x "
|
||||
f"[{vlo['lo']:.2f},{vlo['hi']:.2f}] p={vlo['p']:.3f} "
|
||||
f"vs Box-B2: {vhi['eff']:.2f}x [{vhi['lo']:.2f},{vhi['hi']:.2f}] "
|
||||
f"p={vhi['p']:.3f}")
|
||||
print(f" -> {verdict}")
|
||||
print("\n NOTE: each unknown has ONLY 1 subject. 'Matches' means 'not statistically")
|
||||
print(" distinguishable' — weak evidence at n=1, not proof of equivalence.")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="tDCS GLM hypothesis tests")
|
||||
ap.add_argument("--max-day", type=int, default=None,
|
||||
help="restrict analysis to training days 0..MAX_DAY (default: all)")
|
||||
ap.add_argument("--merge", action="store_true",
|
||||
help="preset: Electrode-Box-A->Box-A2 and Right-Electrode->Box-B2")
|
||||
ap.add_argument("--assign", type=str, default=None,
|
||||
help="custom merges 'UNKNOWN=TARGET,...', e.g. "
|
||||
"'Electrode-Box-A=Electrode-Box-B2,Right-Electrode=Electrode-Box-B2'")
|
||||
args = ap.parse_args()
|
||||
|
||||
mapping = {}
|
||||
if args.merge:
|
||||
mapping.update(MERGE_MAP)
|
||||
if args.assign:
|
||||
for pair in args.assign.split(","):
|
||||
k, v = pair.split("=")
|
||||
mapping[k.strip()] = v.strip()
|
||||
|
||||
df = load(args.max_day, mapping)
|
||||
|
||||
global MAIN, OTHERS, UNKNOWNS
|
||||
present = set(df["group"])
|
||||
MAIN = [g for g in ALL_GROUPS if g in present]
|
||||
OTHERS = [g for g in MAIN if g != REF]
|
||||
UNKNOWNS = [g for g in CANON_UNKNOWNS if g in present]
|
||||
|
||||
window = f"days 0-{args.max_day}" if args.max_day is not None else "all training days (0+)"
|
||||
tag = f"_d0-{args.max_day}" if args.max_day is not None else ""
|
||||
tag += "_merged" if mapping else ""
|
||||
plot_path = os.path.join(HERE, f"tdcs_learning_curves{tag}.png")
|
||||
mode = ("assign " + ", ".join(f"{k.replace('Electrode-','')}->{v.replace('Electrode-','')}"
|
||||
for k, v in mapping.items())) if mapping \
|
||||
else "5 groups (anchors + unknowns)"
|
||||
print(f"ANALYSIS WINDOW: {window} | MODE: {mode} | observations: {len(df)}")
|
||||
describe(df)
|
||||
count = fit_count_level(df)
|
||||
rate = fit_rate_level(df)
|
||||
learn = fit_learning_rate(df)
|
||||
report_level("(A) LEVEL / COUNT — Poisson GEE on successful reaches",
|
||||
count, "incidence-rate ratio")
|
||||
report_level("(B) LEVEL / RATE — Binomial GLM on success/attempts (cluster-robust)",
|
||||
rate, "odds ratio")
|
||||
report_learning(learn)
|
||||
report_anchor_only(df)
|
||||
report_mixed(df)
|
||||
verdicts(count, rate)
|
||||
plot_curves(df, plot_path)
|
||||
print("\nDone. See the module docstring for modeling choices and caveats.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 161 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 154 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
@@ -0,0 +1,194 @@
|
||||
subject,group,day,success,total
|
||||
Vu-vuong,Naive,0,18,61
|
||||
Vu-vuong,Naive,1,35,91
|
||||
Vu-vuong,Naive,2,61,127
|
||||
Vu-vuong,Naive,3,34,146
|
||||
Vu-vuong,Naive,4,61,141
|
||||
Vu-vuong,Naive,5,37,151
|
||||
Vu-vuong,Naive,6,49,131
|
||||
Vu-vuong,Naive,7,65,149
|
||||
Vu-vuong,Naive,8,67,141
|
||||
Vu-vuong,Naive,9,73,140
|
||||
Vu-vuong,Naive,10,72,131
|
||||
Vu-vuong,Naive,11,89,158
|
||||
Vu-vuong,Naive,12,97,154
|
||||
Vu-vuong,Naive,13,93,145
|
||||
Vu-vuong,Naive,14,75,147
|
||||
Vu-vuong,Naive,15,68,142
|
||||
Vu-vuong,Naive,16,96,162
|
||||
Vu-vuong,Naive,17,68,144
|
||||
Vu-vuong,Naive,18,81,134
|
||||
Vu-vuong,Naive,19,66,144
|
||||
Vu-vuong,Naive,20,84,127
|
||||
Vu-vuong,Naive,21,66,133
|
||||
Khoai-tay-1,Electrode-Box-A,-2,1,18
|
||||
Khoai-tay-1,Electrode-Box-A,-1,3,24
|
||||
Khoai-tay-1,Electrode-Box-A,0,14,62
|
||||
Khoai-tay-1,Electrode-Box-A,1,22,83
|
||||
Khoai-tay-1,Electrode-Box-A,2,6,79
|
||||
Khoai-tay-1,Electrode-Box-A,3,28,97
|
||||
Khoai-tay-1,Electrode-Box-A,4,60,134
|
||||
Khoai-tay-1,Electrode-Box-A,5,75,138
|
||||
Khoai-tay-1,Electrode-Box-A,6,81,137
|
||||
Khoai-tay-1,Electrode-Box-A,7,78,147
|
||||
Khoai-tay-1,Electrode-Box-A,8,91,132
|
||||
Khoai-tay-1,Electrode-Box-A,9,99,146
|
||||
Khoai-tay-1,Electrode-Box-A,10,94,143
|
||||
Khoai-tay-1,Electrode-Box-A,11,110,156
|
||||
Khoai-tay-1,Electrode-Box-A,12,105,143
|
||||
Khoai-tay-1,Electrode-Box-A,13,106,153
|
||||
Khoai-tay-1,Electrode-Box-A,14,91,152
|
||||
Banh-mi-2,Electrode-Box-A2,-1,2,42
|
||||
Banh-mi-2,Electrode-Box-A2,0,25,97
|
||||
Banh-mi-2,Electrode-Box-A2,1,22,101
|
||||
Banh-mi-2,Electrode-Box-A2,2,22,119
|
||||
Banh-mi-2,Electrode-Box-A2,3,29,118
|
||||
Banh-mi-2,Electrode-Box-A2,4,27,136
|
||||
Banh-mi-2,Electrode-Box-A2,5,43,146
|
||||
Banh-mi-2,Electrode-Box-A2,6,74,146
|
||||
Banh-mi-2,Electrode-Box-A2,7,70,148
|
||||
Banh-mi-2,Electrode-Box-A2,8,65,130
|
||||
Banh-mi-2,Electrode-Box-A2,9,79,151
|
||||
Banh-mi-2,Electrode-Box-A2,10,93,152
|
||||
Egg-tart-2,Electrode-Box-A2,0,9,32
|
||||
Egg-tart-2,Electrode-Box-A2,1,2,38
|
||||
Egg-tart-2,Electrode-Box-A2,2,31,93
|
||||
Egg-tart-2,Electrode-Box-A2,3,44,101
|
||||
Egg-tart-2,Electrode-Box-A2,4,54,131
|
||||
Egg-tart-2,Electrode-Box-A2,5,84,139
|
||||
Egg-tart-2,Electrode-Box-A2,6,85,145
|
||||
Egg-tart-2,Electrode-Box-A2,7,79,143
|
||||
Egg-tart-2,Electrode-Box-A2,8,76,131
|
||||
Egg-tart-2,Electrode-Box-A2,9,88,149
|
||||
Egg-tart-2,Electrode-Box-A2,10,81,151
|
||||
Egg-tart-2,Electrode-Box-A2,11,78,152
|
||||
Egg-tart-2,Electrode-Box-A2,12,96,155
|
||||
Egg-tart-2,Electrode-Box-A2,13,84,155
|
||||
Root-beer-2,Electrode-Box-A2,0,22,74
|
||||
Root-beer-2,Electrode-Box-A2,1,31,87
|
||||
Root-beer-2,Electrode-Box-A2,2,49,134
|
||||
Root-beer-2,Electrode-Box-A2,3,31,89
|
||||
Root-beer-2,Electrode-Box-A2,4,60,140
|
||||
Root-beer-2,Electrode-Box-A2,5,84,147
|
||||
Banh-mi-1,Electrode-Box-B2,0,13,84
|
||||
Banh-mi-1,Electrode-Box-B2,1,35,86
|
||||
Banh-mi-1,Electrode-Box-B2,2,47,110
|
||||
Banh-mi-1,Electrode-Box-B2,3,65,140
|
||||
Banh-mi-1,Electrode-Box-B2,4,70,127
|
||||
Banh-mi-1,Electrode-Box-B2,5,102,142
|
||||
Banh-mi-1,Electrode-Box-B2,6,90,131
|
||||
Banh-mi-1,Electrode-Box-B2,7,109,148
|
||||
Banh-mi-1,Electrode-Box-B2,8,104,137
|
||||
Banh-mi-1,Electrode-Box-B2,9,119,150
|
||||
Banh-mi-1,Electrode-Box-B2,10,121,158
|
||||
Banh-mi-1,Electrode-Box-B2,11,121,148
|
||||
Banh-mi-1,Electrode-Box-B2,12,120,149
|
||||
Banh-mi-1,Electrode-Box-B2,13,135,154
|
||||
Egg-tart-1,Electrode-Box-B2,0,7,56
|
||||
Egg-tart-1,Electrode-Box-B2,1,16,78
|
||||
Egg-tart-1,Electrode-Box-B2,2,23,103
|
||||
Egg-tart-1,Electrode-Box-B2,3,63,120
|
||||
Egg-tart-1,Electrode-Box-B2,4,69,132
|
||||
Egg-tart-1,Electrode-Box-B2,5,83,136
|
||||
Egg-tart-1,Electrode-Box-B2,6,71,142
|
||||
Egg-tart-1,Electrode-Box-B2,7,79,138
|
||||
Egg-tart-1,Electrode-Box-B2,8,98,142
|
||||
Egg-tart-1,Electrode-Box-B2,9,89,139
|
||||
Egg-tart-1,Electrode-Box-B2,10,96,143
|
||||
Egg-tart-1,Electrode-Box-B2,11,96,148
|
||||
Egg-tart-1,Electrode-Box-B2,12,101,156
|
||||
Egg-tart-1,Electrode-Box-B2,13,103,152
|
||||
Egg-tart-1,Electrode-Box-B2,14,97,152
|
||||
Root-beer-1,Electrode-Box-B2,0,11,85
|
||||
Root-beer-1,Electrode-Box-B2,1,18,76
|
||||
Root-beer-1,Electrode-Box-B2,2,40,105
|
||||
Root-beer-1,Electrode-Box-B2,3,55,134
|
||||
Root-beer-1,Electrode-Box-B2,4,75,136
|
||||
Root-beer-1,Electrode-Box-B2,5,64,133
|
||||
Root-beer-1,Electrode-Box-B2,6,104,139
|
||||
Root-beer-1,Electrode-Box-B2,7,98,148
|
||||
Root-beer-1,Electrode-Box-B2,8,81,145
|
||||
Root-beer-1,Electrode-Box-B2,9,89,156
|
||||
Root-beer-1,Electrode-Box-B2,10,105,158
|
||||
Khoai-lang-2,Naive,0,0,0
|
||||
Khoai-lang-2,Naive,1,0,0
|
||||
Khoai-lang-2,Naive,2,10,47
|
||||
Khoai-lang-2,Naive,3,11,52
|
||||
Khoai-lang-2,Naive,4,9,56
|
||||
Khoai-lang-2,Naive,5,34,95
|
||||
Khoai-lang-2,Naive,6,21,72
|
||||
Khoai-lang-2,Naive,7,23,99
|
||||
Khoai-lang-2,Naive,8,64,136
|
||||
Khoai-lang-2,Naive,9,75,131
|
||||
Khoai-lang-2,Naive,10,63,134
|
||||
Khoai-lang-2,Naive,11,59,139
|
||||
Khoai-lang-2,Naive,12,51,129
|
||||
Khoai-lang-2,Naive,13,73,143
|
||||
Khoai-lang-2,Naive,14,82,136
|
||||
Khoai-lang-2,Naive,15,70,145
|
||||
Khoai-lang-2,Naive,16,76,135
|
||||
Khoai-lang-2,Naive,17,76,150
|
||||
Khoai-lang-2,Naive,18,63,122
|
||||
Khoai-lang-2,Naive,19,48,116
|
||||
Khoai-lang-2,Naive,20,65,134
|
||||
Khoai-lang-2,Naive,21,75,131
|
||||
Khoai-lang-2,Naive,22,98,146
|
||||
Khoai-lang-2,Naive,23,88,139
|
||||
Khoai-lang-2,Naive,24,94,148
|
||||
Khoai-lang-2,Naive,25,56,102
|
||||
Khoai-lang-2,Naive,26,75,143
|
||||
Khoai-tay-2,Naive,1,21,68
|
||||
Khoai-tay-2,Naive,2,6,79
|
||||
Khoai-tay-2,Naive,3,0,83
|
||||
Khoai-tay-2,Naive,4,28,87
|
||||
Khoai-tay-2,Naive,5,31,125
|
||||
Khoai-tay-2,Naive,6,62,144
|
||||
Khoai-tay-2,Naive,7,87,141
|
||||
Khoai-tay-2,Naive,8,105,152
|
||||
Khoai-tay-2,Naive,9,75,148
|
||||
Khoai-tay-2,Naive,10,101,149
|
||||
Khoai-tay-2,Naive,11,102,154
|
||||
Khoai-tay-2,Naive,12,95,144
|
||||
Khoai-tay-2,Naive,13,77,149
|
||||
Khoai-tay-2,Naive,14,91,153
|
||||
OM-2,Naive,-2,0,0
|
||||
OM-2,Naive,-1,5,18
|
||||
OM-2,Naive,0,1,7
|
||||
OM-2,Naive,1,11,33
|
||||
OM-2,Naive,2,19,62
|
||||
OM-2,Naive,3,29,117
|
||||
OM-2,Naive,4,73,126
|
||||
OM-2,Naive,5,53,135
|
||||
OM-2,Naive,6,73,138
|
||||
OM-2,Naive,7,80,131
|
||||
OM-2,Naive,8,91,141
|
||||
OM-2,Naive,9,90,135
|
||||
OM-2,Naive,10,95,142
|
||||
OM-2,Naive,11,60,133
|
||||
OM-2,Naive,12,58,142
|
||||
Khoai-lang-1,Right-Electrode,-4,0,0
|
||||
Khoai-lang-1,Right-Electrode,-3,2,7
|
||||
Khoai-lang-1,Right-Electrode,-1,0,0
|
||||
Khoai-lang-1,Right-Electrode,0,3,69
|
||||
Khoai-lang-1,Right-Electrode,1,18,97
|
||||
Khoai-lang-1,Right-Electrode,2,27,84
|
||||
Khoai-lang-1,Right-Electrode,3,46,121
|
||||
Khoai-lang-1,Right-Electrode,4,65,143
|
||||
Khoai-lang-1,Right-Electrode,5,76,141
|
||||
Khoai-lang-1,Right-Electrode,6,79,146
|
||||
Khoai-lang-1,Right-Electrode,7,81,153
|
||||
Khoai-lang-1,Right-Electrode,8,98,144
|
||||
Khoai-lang-1,Right-Electrode,9,101,149
|
||||
Khoai-lang-1,Right-Electrode,10,103,150
|
||||
Khoai-lang-1,Right-Electrode,11,113,154
|
||||
Khoai-lang-1,Right-Electrode,12,117,148
|
||||
Khoai-lang-1,Right-Electrode,13,114,146
|
||||
Khoai-lang-1,Right-Electrode,14,119,132
|
||||
Khoai-lang-1,Right-Electrode,15,111,156
|
||||
Khoai-lang-1,Right-Electrode,16,74,156
|
||||
Khoai-lang-1,Right-Electrode,17,109,139
|
||||
Khoai-lang-1,Right-Electrode,18,88,138
|
||||
Khoai-lang-1,Right-Electrode,19,98,140
|
||||
Khoai-lang-1,Right-Electrode,20,118,153
|
||||
Khoai-lang-1,Right-Electrode,21,109,146
|
||||
Khoai-lang-1,Right-Electrode,22,106,141
|
||||
|
+9
-1
@@ -1,4 +1,6 @@
|
||||
FROM node:20-alpine AS base
|
||||
# Prisma migration engine requires OpenSSL
|
||||
RUN apk add --no-cache openssl
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production
|
||||
@@ -9,6 +11,12 @@ COPY . .
|
||||
CMD ["npm", "run", "dev"]
|
||||
|
||||
FROM base AS prod
|
||||
# Install prisma CLI for migrate deploy at runtime
|
||||
RUN npm install prisma --save-dev
|
||||
COPY . .
|
||||
# Generate Prisma Client
|
||||
RUN npx prisma generate
|
||||
CMD ["npm", "start"]
|
||||
# Fix ownership so the node user can write engine cache
|
||||
RUN chown -R node:node /app
|
||||
USER node
|
||||
CMD ["/bin/sh", "/app/docker-entrypoint.sh"]
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "Running Prisma migrations..."
|
||||
npx prisma migrate deploy
|
||||
|
||||
echo "Starting server..."
|
||||
exec node src/index.js
|
||||
Generated
+39
@@ -11,6 +11,7 @@
|
||||
"@prisma/client": "^5.14.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.2",
|
||||
"express-rate-limit": "^8.3.2",
|
||||
"express-validator": "^7.1.0",
|
||||
"helmet": "^7.1.0",
|
||||
"morgan": "^1.10.0",
|
||||
@@ -2064,6 +2065,23 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz",
|
||||
"integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==",
|
||||
"dependencies": {
|
||||
"ip-address": "10.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/express-rate-limit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/express-validator": {
|
||||
"version": "7.3.2",
|
||||
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.2.tgz",
|
||||
@@ -2489,6 +2507,14 @@
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@@ -6396,6 +6422,14 @@
|
||||
"vary": "~1.1.2"
|
||||
}
|
||||
},
|
||||
"express-rate-limit": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz",
|
||||
"integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==",
|
||||
"requires": {
|
||||
"ip-address": "10.1.0"
|
||||
}
|
||||
},
|
||||
"express-validator": {
|
||||
"version": "7.3.2",
|
||||
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.2.tgz",
|
||||
@@ -6697,6 +6731,11 @@
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||
},
|
||||
"ip-address": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="
|
||||
},
|
||||
"ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"@prisma/client": "^5.14.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.2",
|
||||
"express-rate-limit": "^8.3.2",
|
||||
"express-validator": "^7.1.0",
|
||||
"helmet": "^7.1.0",
|
||||
"morgan": "^1.10.0",
|
||||
@@ -29,8 +30,9 @@
|
||||
},
|
||||
"jest": {
|
||||
"testEnvironment": "node",
|
||||
"testMatch": ["**/tests/**/*.test.js"],
|
||||
"setupFilesAfterFramework": [],
|
||||
"testMatch": [
|
||||
"**/tests/**/*.test.js"
|
||||
],
|
||||
"globalSetup": "./tests/setup.js",
|
||||
"globalTeardown": "./tests/teardown.js"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Add template field to experiments (JSON array of field definitions)
|
||||
ALTER TABLE "experiments" ADD COLUMN "template" JSONB NOT NULL DEFAULT '[]';
|
||||
|
||||
-- Add custom_fields to daily_statuses (stores values for non-builtin fields)
|
||||
ALTER TABLE "daily_statuses" ADD COLUMN "custom_fields" JSONB;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "experiments" ADD COLUMN "subject_info_template" JSONB NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE "animals" ADD COLUMN "subject_info" JSONB;
|
||||
@@ -0,0 +1,21 @@
|
||||
CREATE TABLE "daily_analyses" (
|
||||
"id" TEXT NOT NULL,
|
||||
"daily_status_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"file_name" TEXT,
|
||||
"timestamp_col" TEXT NOT NULL,
|
||||
"note_col" TEXT NOT NULL,
|
||||
"classifications" JSONB NOT NULL,
|
||||
"total_note_rows" INTEGER NOT NULL,
|
||||
"sequence" JSONB NOT NULL,
|
||||
"category_counts" JSONB NOT NULL,
|
||||
"consecutive_stats" JSONB NOT NULL,
|
||||
"runs" JSONB NOT NULL,
|
||||
CONSTRAINT "daily_analyses_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
ALTER TABLE "daily_analyses"
|
||||
ADD CONSTRAINT "daily_analyses_daily_status_id_fkey"
|
||||
FOREIGN KEY ("daily_status_id")
|
||||
REFERENCES "daily_statuses"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "daily_analyses" ADD COLUMN "session_end_ts" DOUBLE PRECISION;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "daily_statuses" ADD COLUMN "analysis_summary" JSONB;
|
||||
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE "session_files" (
|
||||
"id" TEXT NOT NULL,
|
||||
"daily_status_id" TEXT NOT NULL,
|
||||
"filename" TEXT NOT NULL,
|
||||
"file_size" BIGINT NOT NULL,
|
||||
"file_type" TEXT,
|
||||
"last_modified" BIGINT,
|
||||
"notes" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "session_files_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
ALTER TABLE "session_files"
|
||||
ADD CONSTRAINT "session_files_daily_status_id_fkey"
|
||||
FOREIGN KEY ("daily_status_id")
|
||||
REFERENCES "daily_statuses"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE "session_files"
|
||||
ADD COLUMN "matched_identifier" TEXT,
|
||||
ADD COLUMN "animal_id_string_snap" TEXT,
|
||||
ADD COLUMN "animal_name_snap" TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "animals" ADD COLUMN "csv_analysis_config" JSONB;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "experiments" ADD COLUMN "plot_config" JSONB;
|
||||
@@ -1,5 +1,6 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
@@ -8,38 +9,83 @@ datasource db {
|
||||
}
|
||||
|
||||
model Experiment {
|
||||
id String @id @default(uuid())
|
||||
title String
|
||||
created_at DateTime @default(now())
|
||||
animals Animal[]
|
||||
id String @id @default(uuid())
|
||||
title String
|
||||
created_at DateTime @default(now())
|
||||
template Json @default("[]")
|
||||
subject_info_template Json @default("[]")
|
||||
plot_config Json?
|
||||
animals Animal[]
|
||||
|
||||
@@map("experiments")
|
||||
}
|
||||
|
||||
model Animal {
|
||||
id String @id @default(uuid())
|
||||
experiment_id String
|
||||
animal_id_string String
|
||||
animal_name String
|
||||
experiment Experiment @relation(fields: [experiment_id], references: [id], onDelete: Cascade)
|
||||
daily_statuses DailyStatus[]
|
||||
id String @id @default(uuid())
|
||||
experiment_id String
|
||||
animal_id_string String
|
||||
animal_name String
|
||||
subject_info Json?
|
||||
csv_analysis_config Json?
|
||||
experiment Experiment @relation(fields: [experiment_id], references: [id], onDelete: Cascade)
|
||||
daily_statuses DailyStatus[]
|
||||
|
||||
@@map("animals")
|
||||
}
|
||||
|
||||
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?
|
||||
animal Animal @relation(fields: [animal_id], references: [id], onDelete: Cascade)
|
||||
custom_fields Json?
|
||||
analysis_summary Json?
|
||||
animal Animal @relation(fields: [animal_id], references: [id], onDelete: Cascade)
|
||||
analyses DailyAnalysis[]
|
||||
session_files SessionFile[]
|
||||
|
||||
@@map("daily_statuses")
|
||||
}
|
||||
|
||||
model SessionFile {
|
||||
id String @id @default(uuid())
|
||||
daily_status_id String
|
||||
daily_status DailyStatus @relation(fields: [daily_status_id], references: [id], onDelete: Cascade)
|
||||
filename String
|
||||
file_size BigInt
|
||||
file_type String?
|
||||
last_modified BigInt?
|
||||
matched_identifier String? // the animal ID/name substring that matched in the filename
|
||||
animal_id_string_snap String? // snapshot of animal.animal_id_string at registration time
|
||||
animal_name_snap String? // snapshot of animal.animal_name at registration time
|
||||
notes String?
|
||||
created_at DateTime @default(now())
|
||||
|
||||
@@map("session_files")
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const helmet = require('helmet');
|
||||
const morgan = require('morgan');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { globalErrorHandler } = require('./middleware/errorHandler');
|
||||
|
||||
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 sessionFilesRouter = require('./routes/sessionFiles');
|
||||
|
||||
const app = express();
|
||||
|
||||
// Security headers
|
||||
app.use(helmet({
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||
}));
|
||||
|
||||
// CORS — restrict to the configured frontend origin in production
|
||||
const allowedOrigin = process.env.CORS_ORIGIN || '*';
|
||||
app.use(cors({
|
||||
origin: allowedOrigin,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||
}));
|
||||
|
||||
// Rate limiting — 100 requests per minute per IP on all API routes
|
||||
const apiLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 100,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many requests, please try again later.' },
|
||||
skip: () => process.env.NODE_ENV === 'test',
|
||||
});
|
||||
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
app.use(morgan(process.env.NODE_ENV === 'test' ? 'silent' : process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
|
||||
|
||||
// Health check (no rate limit)
|
||||
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);
|
||||
|
||||
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('/api', sessionFilesRouter);
|
||||
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ error: `Route ${req.method} ${req.path} not found` });
|
||||
});
|
||||
|
||||
app.use(globalErrorHandler);
|
||||
|
||||
module.exports = app;
|
||||
+2
-49
@@ -1,58 +1,13 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const helmet = require('helmet');
|
||||
const morgan = require('morgan');
|
||||
const prisma = require('./lib/prisma');
|
||||
const { globalErrorHandler } = require('./middleware/errorHandler');
|
||||
const app = require('./app');
|
||||
|
||||
const experimentsRouter = require('./routes/experiments');
|
||||
const animalsRouter = require('./routes/animals');
|
||||
const dailyStatusesRouter = require('./routes/dailyStatuses');
|
||||
const auditLogsRouter = require('./routes/auditLogs');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
// Security & utility middleware
|
||||
app.use(helmet());
|
||||
app.use(cors({
|
||||
origin: process.env.CORS_ORIGIN || '*',
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||
}));
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// API routes
|
||||
app.use('/api/experiments', experimentsRouter);
|
||||
app.use('/api/animals', animalsRouter);
|
||||
app.use('/api/daily-statuses', dailyStatusesRouter);
|
||||
app.use('/api/audit-logs', auditLogsRouter);
|
||||
|
||||
// 404 handler for unknown routes
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ error: `Route ${req.method} ${req.path} not found` });
|
||||
});
|
||||
|
||||
// Global error handler (must be last)
|
||||
app.use(globalErrorHandler);
|
||||
|
||||
async function startServer() {
|
||||
try {
|
||||
await prisma.$connect();
|
||||
console.log('Database connected successfully');
|
||||
|
||||
// Run migrations on startup in production
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const { execSync } = require('child_process');
|
||||
execSync('npx prisma migrate deploy', { stdio: 'inherit' });
|
||||
}
|
||||
|
||||
// Note: migrations are run by docker-entrypoint.sh before this process starts
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
});
|
||||
@@ -63,5 +18,3 @@ async function startServer() {
|
||||
}
|
||||
|
||||
startServer();
|
||||
|
||||
module.exports = app; // export for testing
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Lightweight in-process TTL cache with prefix-based invalidation.
|
||||
// Single-process only — suitable for this single-container deployment.
|
||||
|
||||
const DEFAULT_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
const store = new Map(); // key → { value, expiresAt }
|
||||
|
||||
function set(key, value, ttlMs = DEFAULT_TTL_MS) {
|
||||
store.set(key, { value, expiresAt: Date.now() + ttlMs });
|
||||
}
|
||||
|
||||
function get(key) {
|
||||
const entry = store.get(key);
|
||||
if (!entry) return undefined;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
store.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
function del(key) {
|
||||
store.delete(key);
|
||||
}
|
||||
|
||||
// Remove all keys that start with prefix — used to invalidate a whole experiment's worth of cached data.
|
||||
function delByPrefix(prefix) {
|
||||
for (const key of store.keys()) {
|
||||
if (key.startsWith(prefix)) store.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { set, get, del, delByPrefix };
|
||||
@@ -0,0 +1,51 @@
|
||||
const REQUIRED_DEFAULT_BUCKETS = ['Success', 'Failure', 'Other'];
|
||||
const ALLOWED_TYPES = new Set(['note', 'numerical']);
|
||||
|
||||
/**
|
||||
* Validate a csv_analysis_config payload.
|
||||
* @param {unknown} body
|
||||
* @returns {string|null} error message, or null if valid.
|
||||
*/
|
||||
function validateCsvConfig(body) {
|
||||
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
|
||||
return 'Body must be an object';
|
||||
}
|
||||
const { buckets } = body;
|
||||
if (!Array.isArray(buckets)) {
|
||||
return 'buckets must be an array';
|
||||
}
|
||||
|
||||
const seenNames = new Set();
|
||||
for (const b of buckets) {
|
||||
if (b === null || typeof b !== 'object' || Array.isArray(b)) {
|
||||
return 'Each bucket must be an object';
|
||||
}
|
||||
if (typeof b.name !== 'string' || b.name.trim() === '') {
|
||||
return 'Each bucket must have a non-empty name';
|
||||
}
|
||||
if (seenNames.has(b.name)) {
|
||||
return `Duplicate bucket name: ${b.name}`;
|
||||
}
|
||||
seenNames.add(b.name);
|
||||
|
||||
if (!ALLOWED_TYPES.has(b.type)) {
|
||||
return `Invalid type for bucket ${b.name}: ${b.type}`;
|
||||
}
|
||||
if (!Array.isArray(b.notes)) {
|
||||
return `notes must be an array for bucket ${b.name}`;
|
||||
}
|
||||
if (!b.notes.every((n) => typeof n === 'string')) {
|
||||
return `notes must be an array of strings for bucket ${b.name}`;
|
||||
}
|
||||
}
|
||||
|
||||
for (const required of REQUIRED_DEFAULT_BUCKETS) {
|
||||
if (!seenNames.has(required)) {
|
||||
return `Missing required default bucket: ${required}`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = { validateCsvConfig, REQUIRED_DEFAULT_BUCKETS };
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* The default daily-record template applied to every new experiment.
|
||||
*
|
||||
* `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, 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;
|
||||
|
||||
function resolveTemplate(stored) {
|
||||
if (!Array.isArray(stored) || stored.length === 0) return DEFAULT_TEMPLATE;
|
||||
return stored;
|
||||
}
|
||||
|
||||
module.exports = { DEFAULT_TEMPLATE, resolveTemplate, UUID_RE };
|
||||
@@ -21,36 +21,40 @@ function auditMiddleware(tableName, model, idParam = 'id') {
|
||||
// Attach before-state so route handlers can reference it if needed
|
||||
req._auditBefore = before;
|
||||
|
||||
// Wrap res.json to intercept the response
|
||||
const originalJson = res.json.bind(res);
|
||||
res.json = async function (data) {
|
||||
// Only log on successful mutations
|
||||
// Shared audit writer — called by both res.json and res.send interceptors
|
||||
async function writeAuditLog() {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
try {
|
||||
const action = req.method === 'DELETE' ? 'DELETE' : 'UPDATE';
|
||||
const after = action === 'DELETE' ? null : await model.findUnique({ where: { id: recordId } });
|
||||
|
||||
const changes = {
|
||||
before,
|
||||
after,
|
||||
};
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
table_name: tableName,
|
||||
record_id: recordId,
|
||||
action,
|
||||
changes,
|
||||
changes: { before, after },
|
||||
},
|
||||
});
|
||||
} catch (auditErr) {
|
||||
// Audit failures should never block the primary response
|
||||
console.error('Audit log write failed:', auditErr.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap res.json (used by JSON-returning routes)
|
||||
const originalJson = res.json.bind(res);
|
||||
res.json = async function (data) {
|
||||
await writeAuditLog();
|
||||
return originalJson(data);
|
||||
};
|
||||
|
||||
// Wrap res.send (used by DELETE 204 routes)
|
||||
const originalSend = res.send.bind(res);
|
||||
res.send = async function (data) {
|
||||
await writeAuditLog();
|
||||
return originalSend(data);
|
||||
};
|
||||
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
|
||||
@@ -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;
|
||||
@@ -3,9 +3,10 @@ const { body, query } = require('express-validator');
|
||||
const prisma = require('../lib/prisma');
|
||||
const auditMiddleware = require('../middleware/auditMiddleware');
|
||||
const { validateRequest } = require('../middleware/errorHandler');
|
||||
const { validateCsvConfig } = require('../lib/csvConfigValidation');
|
||||
|
||||
const animalValidation = [
|
||||
body('experiment_id').notEmpty().withMessage('experiment_id is required').isUUID().withMessage('experiment_id must be a valid UUID'),
|
||||
body('experiment_id').notEmpty().withMessage('experiment_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('experiment_id must be a valid UUID'),
|
||||
body('animal_id_string').trim().notEmpty().withMessage('animal_id_string is required'),
|
||||
body('animal_name').trim().notEmpty().withMessage('animal_name is required'),
|
||||
];
|
||||
@@ -13,12 +14,13 @@ const animalValidation = [
|
||||
const animalUpdateValidation = [
|
||||
body('animal_id_string').optional().trim().notEmpty().withMessage('animal_id_string cannot be empty'),
|
||||
body('animal_name').optional().trim().notEmpty().withMessage('animal_name cannot be empty'),
|
||||
body('subject_info').optional({ nullable: true }).isObject().withMessage('subject_info must be an object'),
|
||||
];
|
||||
|
||||
// GET /api/animals?experimentId=<uuid> — list for experiment
|
||||
router.get(
|
||||
'/',
|
||||
[query('experimentId').optional().isUUID().withMessage('experimentId must be a valid UUID')],
|
||||
[query('experimentId').optional().matches(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i).withMessage('experimentId must be a valid UUID')],
|
||||
validateRequest,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
@@ -80,10 +82,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 },
|
||||
@@ -110,4 +113,24 @@ router.delete(
|
||||
}
|
||||
);
|
||||
|
||||
// PATCH /api/animals/:id/csv-config — replace entire CSV-analysis bucket config
|
||||
router.patch(
|
||||
'/:id/csv-config',
|
||||
auditMiddleware('animals', prisma.animal),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const err = validateCsvConfig(req.body);
|
||||
if (err) return res.status(400).json({ error: err });
|
||||
|
||||
const animal = await prisma.animal.update({
|
||||
where: { id: req.params.id },
|
||||
data: { csv_analysis_config: req.body },
|
||||
});
|
||||
res.json(animal);
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -7,8 +7,8 @@ const { validateRequest } = require('../middleware/errorHandler');
|
||||
router.get(
|
||||
'/',
|
||||
[
|
||||
query('tableName').optional().isString(),
|
||||
query('recordId').optional().isUUID().withMessage('recordId must be a valid UUID'),
|
||||
query('tableName').optional().isIn(['experiments', 'animals', 'daily_statuses', 'audit_logs']).withMessage('tableName must be one of: experiments, animals, daily_statuses, audit_logs'),
|
||||
query('recordId').optional().matches(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i).withMessage('recordId must be a valid UUID'),
|
||||
query('limit').optional().isInt({ min: 1, max: 200 }).withMessage('limit must be 1–200').toInt(),
|
||||
query('offset').optional().isInt({ min: 0 }).withMessage('offset must be ≥0').toInt(),
|
||||
],
|
||||
|
||||
@@ -1,30 +1,43 @@
|
||||
const router = require('express').Router();
|
||||
const { body, query } = require('express-validator');
|
||||
const prisma = require('../lib/prisma');
|
||||
const cache = require('../lib/cache');
|
||||
const auditMiddleware = require('../middleware/auditMiddleware');
|
||||
const { validateRequest } = require('../middleware/errorHandler');
|
||||
|
||||
// Look up which experiment a daily status belongs to, then clear all cached data for it.
|
||||
async function invalidateByStatusId(statusId) {
|
||||
const rec = await prisma.dailyStatus.findUnique({
|
||||
where: { id: statusId },
|
||||
select: { animal: { select: { experiment_id: true } } },
|
||||
});
|
||||
const expId = rec?.animal?.experiment_id;
|
||||
if (expId) cache.delByPrefix(`exp:${expId}:`);
|
||||
}
|
||||
|
||||
const statusValidation = [
|
||||
body('animal_id').notEmpty().withMessage('animal_id is required').isUUID().withMessage('animal_id must be a valid UUID'),
|
||||
body('animal_id').notEmpty().withMessage('animal_id is required').matches(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i).withMessage('animal_id must be a valid UUID'),
|
||||
body('date').notEmpty().withMessage('date is required').isISO8601().withMessage('date must be a valid ISO 8601 date (YYYY-MM-DD)'),
|
||||
body('experiment_description').optional().isString(),
|
||||
body('vitals').optional().isString(),
|
||||
body('treatment').optional().isString(),
|
||||
body('notes').optional().isString(),
|
||||
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('experiment_description').optional({ nullable: true }).isString(),
|
||||
body('vitals').optional({ nullable: true }).isString(),
|
||||
body('treatment').optional({ nullable: true }).isString(),
|
||||
body('notes').optional({ nullable: true }).isString(),
|
||||
body('custom_fields').optional({ nullable: true }).isObject().withMessage('custom_fields must be an object'),
|
||||
];
|
||||
|
||||
// GET /api/daily-statuses?animalId=<uuid> — list for animal
|
||||
// GET /api/daily-statuses?animalId=<uuid>
|
||||
router.get(
|
||||
'/',
|
||||
[query('animalId').optional().isUUID().withMessage('animalId must be a valid UUID')],
|
||||
[query('animalId').optional().matches(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i).withMessage('animalId must be a valid UUID')],
|
||||
validateRequest,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
@@ -35,13 +48,11 @@ router.get(
|
||||
include: { animal: { select: { animal_name: true, animal_id_string: true } } },
|
||||
});
|
||||
res.json(statuses);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/daily-statuses/:id — get one
|
||||
// GET /api/daily-statuses/:id
|
||||
router.get('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const status = await prisma.dailyStatus.findUnique({
|
||||
@@ -50,15 +61,13 @@ router.get('/:id', async (req, res, next) => {
|
||||
});
|
||||
if (!status) return res.status(404).json({ error: 'Daily status not found' });
|
||||
res.json(status);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /api/daily-statuses — create
|
||||
// POST /api/daily-statuses
|
||||
router.post('/', statusValidation, validateRequest, async (req, res, next) => {
|
||||
try {
|
||||
const { animal_id, date, experiment_description, vitals, treatment, notes } = req.body;
|
||||
const { animal_id, date, experiment_description, vitals, treatment, notes, custom_fields } = req.body;
|
||||
const status = await prisma.dailyStatus.create({
|
||||
data: {
|
||||
animal_id,
|
||||
@@ -67,25 +76,20 @@ router.post('/', statusValidation, validateRequest, async (req, res, next) => {
|
||||
vitals,
|
||||
treatment,
|
||||
notes,
|
||||
custom_fields: custom_fields ?? {},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
table_name: 'daily_statuses',
|
||||
record_id: status.id,
|
||||
action: 'CREATE',
|
||||
changes: { before: null, after: status },
|
||||
},
|
||||
data: { table_name: 'daily_statuses', record_id: status.id, action: 'CREATE', changes: { before: null, after: status } },
|
||||
});
|
||||
|
||||
// Invalidate cached experiment data — new status may change calendar counts and chart data
|
||||
const animal = await prisma.animal.findUnique({ where: { id: animal_id }, select: { experiment_id: true } });
|
||||
if (animal) cache.delByPrefix(`exp:${animal.experiment_id}:`);
|
||||
res.status(201).json(status);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// PUT /api/daily-statuses/:id — update
|
||||
// PUT /api/daily-statuses/:id
|
||||
router.put(
|
||||
'/:id',
|
||||
auditMiddleware('daily_statuses', prisma.dailyStatus),
|
||||
@@ -93,36 +97,68 @@ router.put(
|
||||
validateRequest,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { date, experiment_description, vitals, treatment, notes } = req.body;
|
||||
const { date, experiment_description, vitals, treatment, notes, custom_fields } = req.body;
|
||||
const data = {};
|
||||
if (date !== undefined) data.date = new Date(date);
|
||||
if (experiment_description !== undefined) data.experiment_description = experiment_description;
|
||||
if (vitals !== undefined) data.vitals = vitals;
|
||||
if (treatment !== undefined) data.treatment = treatment;
|
||||
if (notes !== undefined) data.notes = notes;
|
||||
if (date !== undefined) data.date = new Date(date);
|
||||
if (experiment_description !== undefined) data.experiment_description = experiment_description;
|
||||
if (vitals !== undefined) data.vitals = vitals;
|
||||
if (treatment !== undefined) data.treatment = treatment;
|
||||
if (notes !== undefined) data.notes = notes;
|
||||
if (custom_fields !== undefined) data.custom_fields = custom_fields;
|
||||
|
||||
const status = await prisma.dailyStatus.update({
|
||||
where: { id: req.params.id },
|
||||
data,
|
||||
});
|
||||
const status = await prisma.dailyStatus.update({ where: { id: req.params.id }, data });
|
||||
await invalidateByStatusId(req.params.id);
|
||||
res.json(status);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
);
|
||||
|
||||
// DELETE /api/daily-statuses/:id — delete
|
||||
// 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 },
|
||||
include: { animal: { select: { experiment_id: true } } },
|
||||
});
|
||||
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 },
|
||||
});
|
||||
if (status.animal?.experiment_id) cache.delByPrefix(`exp:${status.animal.experiment_id}:`);
|
||||
res.json(updated);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// DELETE /api/daily-statuses/:id
|
||||
router.delete(
|
||||
'/:id',
|
||||
auditMiddleware('daily_statuses', prisma.dailyStatus),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
// Resolve experiment before deleting so we can invalidate after
|
||||
const rec = await prisma.dailyStatus.findUnique({
|
||||
where: { id: req.params.id },
|
||||
select: { animal: { select: { experiment_id: true } } },
|
||||
});
|
||||
await prisma.dailyStatus.delete({ where: { id: req.params.id } });
|
||||
if (rec?.animal?.experiment_id) cache.delByPrefix(`exp:${rec.animal.experiment_id}:`);
|
||||
res.status(204).send();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -1,14 +1,108 @@
|
||||
const router = require('express').Router();
|
||||
const { body } = require('express-validator');
|
||||
const prisma = require('../lib/prisma');
|
||||
const cache = require('../lib/cache');
|
||||
const auditMiddleware = require('../middleware/auditMiddleware');
|
||||
const { validateRequest } = require('../middleware/errorHandler');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { resolveTemplate, DEFAULT_TEMPLATE, UUID_RE } = require('../lib/defaultTemplate');
|
||||
|
||||
const experimentValidation = [
|
||||
body('title').trim().notEmpty().withMessage('Title is required').isLength({ max: 255 }).withMessage('Title must be ≤255 characters'),
|
||||
];
|
||||
|
||||
// GET /api/experiments — list all
|
||||
// ── Template validation helpers ────────────────────────────────────────────────
|
||||
|
||||
const BUILTIN_FIELD_IDS = new Set(DEFAULT_TEMPLATE.map((f) => f.fieldId));
|
||||
const BUILTIN_KEYS = new Set(DEFAULT_TEMPLATE.map((f) => f.key));
|
||||
const VALID_TYPES = new Set(['text', 'textarea']);
|
||||
|
||||
function validateTemplate(template) {
|
||||
if (!Array.isArray(template)) return 'template must be an array';
|
||||
if (template.length > 50) return 'template may have at most 50 fields';
|
||||
|
||||
const seenFieldIds = new Set();
|
||||
const seenKeys = new Set();
|
||||
|
||||
for (const field of template) {
|
||||
// ── fieldId ────────────────────────────────────────────────────────────────
|
||||
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);
|
||||
|
||||
// Builtin fields must keep their original fieldIds
|
||||
if (BUILTIN_KEYS.has(field.key) && !BUILTIN_FIELD_IDS.has(field.fieldId))
|
||||
return `builtin field "${field.key}" must keep its original fieldId`;
|
||||
|
||||
// ── key ────────────────────────────────────────────────────────────────────
|
||||
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);
|
||||
|
||||
// ── label ──────────────────────────────────────────────────────────────────
|
||||
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`;
|
||||
|
||||
// ── type / active / builtin ────────────────────────────────────────────────
|
||||
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 (BUILTIN_KEYS.has(field.key) && field.builtin !== true)
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Back-fill any missing fieldIds (handles records saved before this feature).
|
||||
* Builtin fields get their canonical IDs; custom fields get fresh UUIDs.
|
||||
*/
|
||||
function ensureFieldIds(template) {
|
||||
const builtinById = Object.fromEntries(DEFAULT_TEMPLATE.map((f) => [f.key, f.fieldId]));
|
||||
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)
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Experiment CRUD ────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/experiments
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const experiments = await prisma.experiment.findMany({
|
||||
@@ -16,12 +110,10 @@ router.get('/', async (req, res, next) => {
|
||||
include: { _count: { select: { animals: true } } },
|
||||
});
|
||||
res.json(experiments);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /api/experiments/:id — get one with animals
|
||||
// GET /api/experiments/:id
|
||||
router.get('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const experiment = await prisma.experiment.findUnique({
|
||||
@@ -29,35 +121,25 @@ router.get('/:id', async (req, res, next) => {
|
||||
include: { animals: true },
|
||||
});
|
||||
if (!experiment) return res.status(404).json({ error: 'Experiment not found' });
|
||||
experiment.template = ensureFieldIds(resolveTemplate(experiment.template));
|
||||
res.json(experiment);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /api/experiments — create
|
||||
// POST /api/experiments
|
||||
router.post('/', experimentValidation, validateRequest, async (req, res, next) => {
|
||||
try {
|
||||
const { title } = req.body;
|
||||
const experiment = await prisma.experiment.create({ data: { title } });
|
||||
|
||||
// Write CREATE audit log
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
table_name: 'experiments',
|
||||
record_id: experiment.id,
|
||||
action: 'CREATE',
|
||||
changes: { before: null, after: experiment },
|
||||
},
|
||||
data: { table_name: 'experiments', record_id: experiment.id, action: 'CREATE', changes: { before: null, after: experiment } },
|
||||
});
|
||||
|
||||
experiment.template = ensureFieldIds(resolveTemplate(experiment.template));
|
||||
res.status(201).json(experiment);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// PUT /api/experiments/:id — update
|
||||
// PUT /api/experiments/:id
|
||||
router.put(
|
||||
'/:id',
|
||||
auditMiddleware('experiments', prisma.experiment),
|
||||
@@ -66,18 +148,14 @@ router.put(
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { title } = req.body;
|
||||
const experiment = await prisma.experiment.update({
|
||||
where: { id: req.params.id },
|
||||
data: { title },
|
||||
});
|
||||
const experiment = await prisma.experiment.update({ where: { id: req.params.id }, data: { title } });
|
||||
experiment.template = ensureFieldIds(resolveTemplate(experiment.template));
|
||||
res.json(experiment);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
);
|
||||
|
||||
// DELETE /api/experiments/:id — delete
|
||||
// DELETE /api/experiments/:id
|
||||
router.delete(
|
||||
'/:id',
|
||||
auditMiddleware('experiments', prisma.experiment),
|
||||
@@ -85,10 +163,283 @@ router.delete(
|
||||
try {
|
||||
await prisma.experiment.delete({ where: { id: req.params.id } });
|
||||
res.status(204).send();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
);
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
|
||||
// Upper bound on the stored UI-preference blob. Sized to comfortably hold the known
|
||||
// fields plus a full hiddenSubjects list (≤500 UUIDs ≈ 20 KB) with headroom, while
|
||||
// still guarding against a client persisting an arbitrarily large object.
|
||||
const PLOT_CONFIG_MAX_BYTES = 64 * 1024;
|
||||
|
||||
// The blob is intentionally schema-loose (clients may add display keys over time);
|
||||
// only hiddenSubjects is structurally validated since the backend reasons about it.
|
||||
function validatePlotConfig(config) {
|
||||
if (config === null || typeof config !== 'object' || Array.isArray(config)) {
|
||||
return 'plot_config must be an object';
|
||||
}
|
||||
// req.body is already JSON-parsed, so stringify cannot throw here.
|
||||
if (Buffer.byteLength(JSON.stringify(config), 'utf8') > PLOT_CONFIG_MAX_BYTES) {
|
||||
return 'plot_config too large';
|
||||
}
|
||||
if ('hiddenSubjects' in config) {
|
||||
const hidden = config.hiddenSubjects;
|
||||
if (!Array.isArray(hidden) || hidden.length > 500 || !hidden.every((x) => typeof x === 'string')) {
|
||||
return 'hiddenSubjects invalid';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Template endpoints ─────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/experiments/:id/template
|
||||
router.get('/:id/template', async (req, res, next) => {
|
||||
try {
|
||||
const experiment = await prisma.experiment.findUnique({
|
||||
where: { id: req.params.id },
|
||||
select: { id: true, template: true },
|
||||
});
|
||||
if (!experiment) return res.status(404).json({ error: 'Experiment not found' });
|
||||
res.json(ensureFieldIds(resolveTemplate(experiment.template)));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// PUT /api/experiments/:id/template
|
||||
router.put('/:id/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 = validateTemplate(template);
|
||||
if (validationError) return res.status(422).json({ error: validationError });
|
||||
|
||||
const before = ensureFieldIds(resolveTemplate(experiment.template));
|
||||
const updated = await prisma.experiment.update({
|
||||
where: { id: req.params.id },
|
||||
data: { template },
|
||||
});
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
table_name: 'experiments',
|
||||
record_id: experiment.id,
|
||||
action: 'UPDATE',
|
||||
changes: { before: { template: before }, after: { template } },
|
||||
},
|
||||
});
|
||||
|
||||
res.json(template);
|
||||
} 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); }
|
||||
});
|
||||
|
||||
// PUT /api/experiments/:id/plot-config — persist UI display settings (no audit log)
|
||||
router.put('/:id/plot-config', 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 config = req.body;
|
||||
const validationError = validatePlotConfig(config);
|
||||
if (validationError) return res.status(422).json({ error: validationError });
|
||||
|
||||
await prisma.experiment.update({
|
||||
where: { id: req.params.id },
|
||||
data: { plot_config: config },
|
||||
});
|
||||
res.json(config);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /api/experiments/:id/calendar?field=<fieldId|builtinKey>
|
||||
// Returns { field, days: { "YYYY-MM-DD": count } } where count = subjects with non-empty value that day.
|
||||
router.get('/:id/calendar', async (req, res, next) => {
|
||||
try {
|
||||
const field = req.query.field ?? '';
|
||||
const cacheKey = `exp:${req.params.id}:cal:${field}`;
|
||||
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) return res.json(cached);
|
||||
|
||||
const exp = await prisma.experiment.findUnique({ where: { id: req.params.id } });
|
||||
if (!exp) return res.status(404).json({ error: 'Experiment not found' });
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const result = { field, days };
|
||||
cache.set(cacheKey, result);
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /api/experiments/:id/daily-statuses — all statuses for all subjects, analysis_summary included
|
||||
// ?date=YYYY-MM-DD — filter to a single day
|
||||
// ?analysisOnly=true — only return statuses that have a saved analysis_summary (for charts)
|
||||
router.get('/:id/daily-statuses', async (req, res, next) => {
|
||||
try {
|
||||
const date = req.query.date ?? '';
|
||||
const analysisOnly = req.query.analysisOnly === 'true';
|
||||
const cacheKey = `exp:${req.params.id}:ds:${analysisOnly ? '1' : '0'}:${date}`;
|
||||
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) return res.json(cached);
|
||||
|
||||
const exp = await prisma.experiment.findUnique({ where: { id: req.params.id } });
|
||||
if (!exp) return res.status(404).json({ error: 'Experiment not found' });
|
||||
|
||||
const where = { animal: { experiment_id: req.params.id } };
|
||||
if (date) {
|
||||
const d = new Date(date + 'T00:00:00.000Z');
|
||||
const next = new Date(d); next.setUTCDate(next.getUTCDate() + 1);
|
||||
where.date = { gte: d, lt: next };
|
||||
}
|
||||
|
||||
let statuses = await prisma.dailyStatus.findMany({
|
||||
where,
|
||||
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' },
|
||||
});
|
||||
|
||||
// Prisma 5 has no clean IS NOT NULL filter for Json? fields; filter in JS.
|
||||
// The result is cached so this only runs once per cache period.
|
||||
if (analysisOnly) {
|
||||
statuses = statuses.filter((s) => s.analysis_summary !== null);
|
||||
}
|
||||
|
||||
cache.set(cacheKey, statuses);
|
||||
res.json(statuses);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
const router = require('express').Router();
|
||||
const prisma = require('../lib/prisma');
|
||||
|
||||
// POST /api/daily-statuses/:id/files — register file metadata (batch)
|
||||
// Body: [{ filename, file_size, file_type, last_modified,
|
||||
// matched_identifier, animal_id_string_snap, animal_name_snap, notes }, ...]
|
||||
router.post('/daily-statuses/:id/files', 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 files = req.body;
|
||||
if (!Array.isArray(files) || files.length === 0)
|
||||
return res.status(422).json({ error: 'Request body must be a non-empty array of file metadata' });
|
||||
|
||||
if (files.length > 200)
|
||||
return res.status(422).json({ error: 'At most 200 files per request' });
|
||||
|
||||
for (const f of files) {
|
||||
if (!f.filename || typeof f.filename !== 'string' || !f.filename.trim())
|
||||
return res.status(422).json({ error: 'Each file must have a non-empty filename' });
|
||||
if (f.file_size == null || typeof f.file_size !== 'number' || f.file_size < 0)
|
||||
return res.status(422).json({ error: `file_size must be a non-negative number (got: ${f.file_size})` });
|
||||
}
|
||||
|
||||
const created = await prisma.$transaction(
|
||||
files.map((f) =>
|
||||
prisma.sessionFile.create({
|
||||
data: {
|
||||
daily_status_id: req.params.id,
|
||||
filename: f.filename.trim(),
|
||||
file_size: BigInt(Math.round(f.file_size)),
|
||||
file_type: f.file_type ?? null,
|
||||
last_modified: f.last_modified != null ? BigInt(Math.round(f.last_modified)) : null,
|
||||
matched_identifier: f.matched_identifier ?? null,
|
||||
animal_id_string_snap: f.animal_id_string_snap ?? null,
|
||||
animal_name_snap: f.animal_name_snap ?? null,
|
||||
notes: f.notes ?? null,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
res.status(201).json(created.map(serializeFile));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /api/daily-statuses/:id/files
|
||||
router.get('/daily-statuses/:id/files', 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 files = await prisma.sessionFile.findMany({
|
||||
where: { daily_status_id: req.params.id },
|
||||
orderBy: { created_at: 'asc' },
|
||||
});
|
||||
res.json(files.map(serializeFile));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// DELETE /api/session-files/:id
|
||||
router.delete('/session-files/:id', async (req, res, next) => {
|
||||
try {
|
||||
const file = await prisma.sessionFile.findUnique({ where: { id: req.params.id } });
|
||||
if (!file) return res.status(404).json({ error: 'Session file not found' });
|
||||
await prisma.sessionFile.delete({ where: { id: req.params.id } });
|
||||
res.status(204).send();
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// BigInt fields must be serialized to strings to survive JSON.stringify
|
||||
function serializeFile(f) {
|
||||
return {
|
||||
...f,
|
||||
file_size: f.file_size.toString(),
|
||||
last_modified: f.last_modified != null ? f.last_modified.toString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,37 @@
|
||||
// Shared Prisma mock — individual test files override methods as needed.
|
||||
const prismaMock = {
|
||||
experiment: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
count: jest.fn(),
|
||||
},
|
||||
animal: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
count: jest.fn(),
|
||||
},
|
||||
dailyStatus: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
count: jest.fn(),
|
||||
},
|
||||
auditLog: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
count: jest.fn(),
|
||||
},
|
||||
$connect: jest.fn(),
|
||||
$disconnect: jest.fn(),
|
||||
};
|
||||
|
||||
module.exports = prismaMock;
|
||||
@@ -0,0 +1,190 @@
|
||||
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 = 'bbbbbbbb-0000-0000-0000-000000000001';
|
||||
const ANIMAL = {
|
||||
id: 'cccccccc-0000-0000-0000-000000000001',
|
||||
experiment_id: EXP_ID,
|
||||
animal_id_string: 'M-001',
|
||||
animal_name: 'Whiskers',
|
||||
_count: { daily_statuses: 0 },
|
||||
};
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
describe('GET /api/animals', () => {
|
||||
it('returns animals filtered by experimentId', async () => {
|
||||
prisma.animal.findMany.mockResolvedValue([ANIMAL]);
|
||||
const res = await request(app).get(`/api/animals?experimentId=${EXP_ID}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(1);
|
||||
expect(res.body[0].animal_id_string).toBe('M-001');
|
||||
});
|
||||
|
||||
it('returns 422 for invalid UUID experimentId', async () => {
|
||||
const res = await request(app).get('/api/animals?experimentId=not-a-uuid');
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/animals/:id', () => {
|
||||
it('returns animal with daily_statuses', async () => {
|
||||
prisma.animal.findUnique.mockResolvedValue({ ...ANIMAL, daily_statuses: [] });
|
||||
const res = await request(app).get(`/api/animals/${ANIMAL.id}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.animal_name).toBe('Whiskers');
|
||||
});
|
||||
|
||||
it('returns 404 for unknown animal', async () => {
|
||||
prisma.animal.findUnique.mockResolvedValue(null);
|
||||
const res = await request(app).get('/api/animals/00000000-0000-0000-0000-000000000000');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/animals', () => {
|
||||
it('creates animal with UUID and writes CREATE audit log', async () => {
|
||||
prisma.animal.create.mockResolvedValue(ANIMAL);
|
||||
prisma.auditLog.create.mockResolvedValue({});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/animals')
|
||||
.send({ experiment_id: EXP_ID, animal_id_string: 'M-001', animal_name: 'Whiskers' });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.id).toMatch(/^[0-9a-f-]{36}$/i);
|
||||
expect(prisma.auditLog.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 422 when experiment_id is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/animals')
|
||||
.send({ animal_id_string: 'M-001', animal_name: 'Whiskers' });
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.details).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ field: 'experiment_id' })])
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 422 when experiment_id is not a UUID', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/animals')
|
||||
.send({ experiment_id: 'bad-id', animal_id_string: 'M-001', animal_name: 'Whiskers' });
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it('returns 422 when animal_name is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/animals')
|
||||
.send({ experiment_id: EXP_ID, animal_id_string: 'M-001' });
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/animals/:id', () => {
|
||||
it('updates animal and writes UPDATE audit log', async () => {
|
||||
const updated = { ...ANIMAL, animal_name: 'Mr Whiskers' };
|
||||
prisma.animal.findUnique
|
||||
.mockResolvedValueOnce(ANIMAL)
|
||||
.mockResolvedValueOnce(updated);
|
||||
prisma.animal.update.mockResolvedValue(updated);
|
||||
prisma.auditLog.create.mockResolvedValue({});
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/animals/${ANIMAL.id}`)
|
||||
.send({ animal_name: 'Mr Whiskers' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.animal_name).toBe('Mr Whiskers');
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ action: 'UPDATE' }) })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/animals/:id', () => {
|
||||
it('deletes animal and writes DELETE audit log', async () => {
|
||||
prisma.animal.findUnique.mockResolvedValue(ANIMAL);
|
||||
prisma.animal.delete.mockResolvedValue(ANIMAL);
|
||||
prisma.auditLog.create.mockResolvedValue({});
|
||||
|
||||
const res = await request(app).delete(`/api/animals/${ANIMAL.id}`);
|
||||
expect(res.status).toBe(204);
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ action: 'DELETE' }) })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/animals/:id/csv-config', () => {
|
||||
const VALID_CONFIG = {
|
||||
buckets: [
|
||||
{ name: 'Success', type: 'note', notes: ['s'] },
|
||||
{ name: 'Failure', type: 'note', notes: ['f'] },
|
||||
{ name: 'Other', type: 'note', notes: [] },
|
||||
{ name: 'Lick latency', type: 'numerical', notes: ['L25', 'L30'] },
|
||||
],
|
||||
};
|
||||
|
||||
it('updates csv_analysis_config and writes audit log', async () => {
|
||||
prisma.animal.findUnique.mockResolvedValue(ANIMAL);
|
||||
prisma.animal.update.mockResolvedValue({ ...ANIMAL, csv_analysis_config: VALID_CONFIG });
|
||||
prisma.auditLog.create.mockResolvedValue({});
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/api/animals/${ANIMAL.id}/csv-config`)
|
||||
.send(VALID_CONFIG);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.csv_analysis_config).toEqual(VALID_CONFIG);
|
||||
expect(prisma.animal.update).toHaveBeenCalledWith({
|
||||
where: { id: ANIMAL.id },
|
||||
data: { csv_analysis_config: VALID_CONFIG },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 400 when buckets is missing', async () => {
|
||||
const res = await request(app)
|
||||
.patch(`/api/animals/${ANIMAL.id}/csv-config`)
|
||||
.send({});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/buckets/);
|
||||
});
|
||||
|
||||
it('returns 400 for empty bucket name', async () => {
|
||||
const bad = { buckets: [...VALID_CONFIG.buckets, { name: ' ', type: 'note', notes: [] }] };
|
||||
const res = await request(app)
|
||||
.patch(`/api/animals/${ANIMAL.id}/csv-config`)
|
||||
.send(bad);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 for duplicate names', async () => {
|
||||
const bad = { buckets: [...VALID_CONFIG.buckets, { name: 'Success', type: 'note', notes: [] }] };
|
||||
const res = await request(app)
|
||||
.patch(`/api/animals/${ANIMAL.id}/csv-config`)
|
||||
.send(bad);
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/duplicate/i);
|
||||
});
|
||||
|
||||
it('returns 400 for invalid type', async () => {
|
||||
const bad = { buckets: [...VALID_CONFIG.buckets, { name: 'X', type: 'weird', notes: [] }] };
|
||||
const res = await request(app)
|
||||
.patch(`/api/animals/${ANIMAL.id}/csv-config`)
|
||||
.send(bad);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 when missing default bucket', async () => {
|
||||
const bad = { buckets: VALID_CONFIG.buckets.filter((b) => b.name !== 'Success') };
|
||||
const res = await request(app)
|
||||
.patch(`/api/animals/${ANIMAL.id}/csv-config`)
|
||||
.send(bad);
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/Success/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
const request = require('supertest');
|
||||
|
||||
jest.mock('../src/lib/prisma', () => require('./__mocks__/prisma'));
|
||||
const prisma = require('../src/lib/prisma');
|
||||
const app = require('../src/app');
|
||||
|
||||
const LOG = {
|
||||
id: 'ffffffff-0000-0000-0000-000000000001',
|
||||
table_name: 'experiments',
|
||||
record_id: 'aaaaaaaa-0000-0000-0000-000000000001',
|
||||
action: 'UPDATE',
|
||||
changes: { before: { title: 'Old' }, after: { title: 'New' } },
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
describe('GET /api/audit-logs', () => {
|
||||
it('returns paginated logs', async () => {
|
||||
prisma.auditLog.findMany.mockResolvedValue([LOG]);
|
||||
prisma.auditLog.count.mockResolvedValue(1);
|
||||
|
||||
const res = await request(app).get('/api/audit-logs');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.logs).toHaveLength(1);
|
||||
expect(res.body.total).toBe(1);
|
||||
expect(res.body.logs[0].action).toBe('UPDATE');
|
||||
});
|
||||
|
||||
it('filters by tableName', async () => {
|
||||
prisma.auditLog.findMany.mockResolvedValue([LOG]);
|
||||
prisma.auditLog.count.mockResolvedValue(1);
|
||||
|
||||
const res = await request(app).get('/api/audit-logs?tableName=experiments');
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.auditLog.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { table_name: 'experiments' } })
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 422 for invalid tableName (not in allowlist)', async () => {
|
||||
const res = await request(app).get('/api/audit-logs?tableName=users');
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it('returns 422 for invalid recordId', async () => {
|
||||
const res = await request(app).get('/api/audit-logs?recordId=not-a-uuid');
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it('respects limit and offset params', async () => {
|
||||
prisma.auditLog.findMany.mockResolvedValue([]);
|
||||
prisma.auditLog.count.mockResolvedValue(0);
|
||||
|
||||
const res = await request(app).get('/api/audit-logs?limit=10&offset=20');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.limit).toBe(10);
|
||||
expect(res.body.offset).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/audit-logs/:id', () => {
|
||||
it('returns a single log entry', async () => {
|
||||
prisma.auditLog.findUnique.mockResolvedValue(LOG);
|
||||
const res = await request(app).get(`/api/audit-logs/${LOG.id}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.action).toBe('UPDATE');
|
||||
});
|
||||
|
||||
it('returns 404 for unknown log id', async () => {
|
||||
prisma.auditLog.findUnique.mockResolvedValue(null);
|
||||
const res = await request(app).get('/api/audit-logs/00000000-0000-0000-0000-000000000000');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
const request = require('supertest');
|
||||
|
||||
jest.mock('../src/lib/prisma', () => require('./__mocks__/prisma'));
|
||||
jest.mock('../src/lib/cache', () => ({ get: jest.fn(() => undefined), set: jest.fn(), del: jest.fn(), delByPrefix: jest.fn() }));
|
||||
|
||||
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({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
const { validateCsvConfig } = require('../src/lib/csvConfigValidation');
|
||||
|
||||
const okBuckets = [
|
||||
{ name: 'Success', type: 'note', notes: ['s'] },
|
||||
{ name: 'Failure', type: 'note', notes: ['f'] },
|
||||
{ name: 'Other', type: 'note', notes: [] },
|
||||
];
|
||||
|
||||
describe('validateCsvConfig', () => {
|
||||
it('accepts a valid config with the three default buckets', () => {
|
||||
expect(validateCsvConfig({ buckets: okBuckets })).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts a valid config with extra note + numerical buckets', () => {
|
||||
const buckets = [
|
||||
...okBuckets,
|
||||
{ name: 'Lick latency', type: 'numerical', notes: ['L25'] },
|
||||
{ name: 'Bonus', type: 'note', notes: ['b'] },
|
||||
];
|
||||
expect(validateCsvConfig({ buckets })).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects when body is not an object', () => {
|
||||
expect(validateCsvConfig(null)).toMatch(/object/i);
|
||||
expect(validateCsvConfig('x')).toMatch(/object/i);
|
||||
});
|
||||
|
||||
it('rejects when buckets is not an array', () => {
|
||||
expect(validateCsvConfig({ buckets: 'nope' })).toMatch(/array/i);
|
||||
});
|
||||
|
||||
it('rejects an empty bucket name', () => {
|
||||
const buckets = [...okBuckets, { name: ' ', type: 'note', notes: [] }];
|
||||
expect(validateCsvConfig({ buckets })).toMatch(/name/i);
|
||||
});
|
||||
|
||||
it('rejects duplicate bucket names', () => {
|
||||
const buckets = [...okBuckets, { name: 'Success', type: 'note', notes: [] }];
|
||||
expect(validateCsvConfig({ buckets })).toMatch(/duplicate/i);
|
||||
});
|
||||
|
||||
it('rejects an invalid type value', () => {
|
||||
const buckets = [...okBuckets, { name: 'X', type: 'weird', notes: [] }];
|
||||
expect(validateCsvConfig({ buckets })).toMatch(/type/i);
|
||||
});
|
||||
|
||||
it('rejects when notes is not an array', () => {
|
||||
const buckets = [...okBuckets, { name: 'X', type: 'note', notes: 'oops' }];
|
||||
expect(validateCsvConfig({ buckets })).toMatch(/notes/i);
|
||||
});
|
||||
|
||||
it('rejects when notes contains non-strings', () => {
|
||||
const buckets = [...okBuckets, { name: 'X', type: 'note', notes: [1, 2] }];
|
||||
expect(validateCsvConfig({ buckets })).toMatch(/notes/i);
|
||||
});
|
||||
|
||||
it('rejects when a default bucket is missing', () => {
|
||||
const buckets = okBuckets.filter((b) => b.name !== 'Success');
|
||||
expect(validateCsvConfig({ buckets })).toMatch(/Success/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
const request = require('supertest');
|
||||
|
||||
jest.mock('../src/lib/prisma', () => require('./__mocks__/prisma'));
|
||||
const prisma = require('../src/lib/prisma');
|
||||
const app = require('../src/app');
|
||||
|
||||
const ANIMAL_ID = 'dddddddd-0000-0000-0000-000000000001';
|
||||
const STATUS = {
|
||||
id: 'eeeeeeee-0000-0000-0000-000000000001',
|
||||
animal_id: ANIMAL_ID,
|
||||
date: new Date('2024-06-01').toISOString(),
|
||||
experiment_description: 'Baseline measurement',
|
||||
vitals: 'HR 72, Temp 37.2',
|
||||
treatment: 'Control',
|
||||
notes: 'Healthy',
|
||||
};
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
describe('GET /api/daily-statuses', () => {
|
||||
it('returns statuses filtered by animalId', async () => {
|
||||
prisma.dailyStatus.findMany.mockResolvedValue([STATUS]);
|
||||
const res = await request(app).get(`/api/daily-statuses?animalId=${ANIMAL_ID}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(1);
|
||||
expect(res.body[0].vitals).toBe('HR 72, Temp 37.2');
|
||||
});
|
||||
|
||||
it('returns 422 for invalid animalId UUID', async () => {
|
||||
const res = await request(app).get('/api/daily-statuses?animalId=invalid');
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/daily-statuses/:id', () => {
|
||||
it('returns a single status entry', async () => {
|
||||
prisma.dailyStatus.findUnique.mockResolvedValue({ ...STATUS, animal: {} });
|
||||
const res = await request(app).get(`/api/daily-statuses/${STATUS.id}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.id).toBe(STATUS.id);
|
||||
});
|
||||
|
||||
it('returns 404 for unknown id', async () => {
|
||||
prisma.dailyStatus.findUnique.mockResolvedValue(null);
|
||||
const res = await request(app).get('/api/daily-statuses/00000000-0000-0000-0000-000000000000');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/daily-statuses', () => {
|
||||
it('creates status and returns 201 with UUID', async () => {
|
||||
prisma.dailyStatus.create.mockResolvedValue(STATUS);
|
||||
prisma.auditLog.create.mockResolvedValue({});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/daily-statuses')
|
||||
.send({ animal_id: ANIMAL_ID, date: '2024-06-01', vitals: 'HR 72' });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.id).toMatch(/^[0-9a-f-]{36}$/i);
|
||||
expect(prisma.auditLog.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 422 when animal_id is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/daily-statuses')
|
||||
.send({ date: '2024-06-01' });
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.details).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ field: 'animal_id' })])
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 422 when date is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/daily-statuses')
|
||||
.send({ animal_id: ANIMAL_ID });
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.details).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ field: 'date' })])
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 422 when date is not a valid ISO date', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/daily-statuses')
|
||||
.send({ animal_id: ANIMAL_ID, date: 'not-a-date' });
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/daily-statuses/:id', () => {
|
||||
it('updates status and writes UPDATE audit log', async () => {
|
||||
const updated = { ...STATUS, vitals: 'HR 80' };
|
||||
prisma.dailyStatus.findUnique
|
||||
.mockResolvedValueOnce(STATUS)
|
||||
.mockResolvedValueOnce(updated);
|
||||
prisma.dailyStatus.update.mockResolvedValue(updated);
|
||||
prisma.auditLog.create.mockResolvedValue({});
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/daily-statuses/${STATUS.id}`)
|
||||
.send({ vitals: 'HR 80' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.vitals).toBe('HR 80');
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ action: 'UPDATE' }) })
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 422 when date format is invalid', async () => {
|
||||
prisma.dailyStatus.findUnique.mockResolvedValue(STATUS);
|
||||
const res = await request(app)
|
||||
.put(`/api/daily-statuses/${STATUS.id}`)
|
||||
.send({ date: 'garbage' });
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/daily-statuses/:id', () => {
|
||||
it('deletes status and writes DELETE audit log', async () => {
|
||||
prisma.dailyStatus.findUnique.mockResolvedValue(STATUS);
|
||||
prisma.dailyStatus.delete.mockResolvedValue(STATUS);
|
||||
prisma.auditLog.create.mockResolvedValue({});
|
||||
|
||||
const res = await request(app).delete(`/api/daily-statuses/${STATUS.id}`);
|
||||
expect(res.status).toBe(204);
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ action: 'DELETE' }) })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
const request = require('supertest');
|
||||
|
||||
jest.mock('../src/lib/prisma', () => require('./__mocks__/prisma'));
|
||||
// Always miss the cache in unit tests so each test exercises the real route logic.
|
||||
jest.mock('../src/lib/cache', () => ({ get: jest.fn(() => undefined), set: jest.fn(), del: jest.fn(), delByPrefix: jest.fn() }));
|
||||
|
||||
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: 'DS Test Study',
|
||||
created_at: new Date('2024-01-01').toISOString(),
|
||||
};
|
||||
|
||||
function makeStatus(overrides = {}) {
|
||||
return {
|
||||
id: overrides.id ?? 'dddddddd-0000-0000-0000-000000000001',
|
||||
animal_id: overrides.animalId ?? 'bbbbbbbb-0000-0000-0000-000000000001',
|
||||
date: new Date(overrides.date ?? '2026-04-01'),
|
||||
analysis_summary: overrides.analysis_summary ?? null,
|
||||
experiment_description: overrides.experiment_description ?? null,
|
||||
vitals: overrides.vitals ?? null,
|
||||
treatment: overrides.treatment ?? null,
|
||||
notes: overrides.notes ?? null,
|
||||
custom_fields: overrides.custom_fields ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── GET /api/experiments/:id/daily-statuses ────────────────────────────────────
|
||||
|
||||
describe('GET /api/experiments/:id/daily-statuses', () => {
|
||||
it('returns 404 when experiment does not exist', async () => {
|
||||
prisma.experiment.findUnique.mockResolvedValue(null);
|
||||
const res = await request(app).get(`/api/experiments/${EXP_ID}/daily-statuses`);
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toMatch(/not found/i);
|
||||
});
|
||||
|
||||
it('returns all statuses when no filters are provided', async () => {
|
||||
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
|
||||
const statuses = [
|
||||
makeStatus({ id: 'id-1', analysis_summary: { total: 10, counts: { Success: 8 }, success_rate: 0.8 } }),
|
||||
makeStatus({ id: 'id-2', analysis_summary: null }),
|
||||
makeStatus({ id: 'id-3', analysis_summary: null }),
|
||||
];
|
||||
prisma.dailyStatus.findMany.mockResolvedValue(statuses);
|
||||
|
||||
const res = await request(app).get(`/api/experiments/${EXP_ID}/daily-statuses`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(3);
|
||||
});
|
||||
|
||||
// ── analysisOnly filter — this is the scenario that caused a 500 ──────────
|
||||
|
||||
it('analysisOnly=true returns only statuses with non-null analysis_summary', async () => {
|
||||
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
|
||||
const summary = { total: 20, counts: { Success: 15, Failure: 5 }, success_rate: 0.75 };
|
||||
prisma.dailyStatus.findMany.mockResolvedValue([
|
||||
makeStatus({ id: 'id-1', analysis_summary: summary }),
|
||||
makeStatus({ id: 'id-2', analysis_summary: null }), // must be excluded
|
||||
makeStatus({ id: 'id-3', analysis_summary: null }), // must be excluded
|
||||
makeStatus({ id: 'id-4', analysis_summary: summary }),
|
||||
]);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/experiments/${EXP_ID}/daily-statuses?analysisOnly=true`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(2);
|
||||
expect(res.body.every((s) => s.analysis_summary !== null)).toBe(true);
|
||||
expect(res.body.map((s) => s.id)).toEqual(['id-1', 'id-4']);
|
||||
});
|
||||
|
||||
it('analysisOnly=true returns empty array when no statuses have analysis_summary', async () => {
|
||||
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
|
||||
prisma.dailyStatus.findMany.mockResolvedValue([
|
||||
makeStatus({ id: 'id-1', analysis_summary: null }),
|
||||
makeStatus({ id: 'id-2', analysis_summary: null }),
|
||||
]);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/experiments/${EXP_ID}/daily-statuses?analysisOnly=true`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
|
||||
it('analysisOnly=true does not 500 when Prisma returns mixed null / object values', async () => {
|
||||
// This is the exact shape that caused the production 500 — the fix must not
|
||||
// rely on a Prisma WHERE clause (which errors on Json? null filtering in v5).
|
||||
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
|
||||
prisma.dailyStatus.findMany.mockResolvedValue([
|
||||
makeStatus({ id: 'id-null', analysis_summary: null }),
|
||||
makeStatus({ id: 'id-value', analysis_summary: { total: 5, counts: {}, success_rate: null } }),
|
||||
]);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/experiments/${EXP_ID}/daily-statuses?analysisOnly=true`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(1);
|
||||
expect(res.body[0].id).toBe('id-value');
|
||||
});
|
||||
|
||||
it('analysisOnly=false (explicit) returns all statuses', async () => {
|
||||
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
|
||||
const statuses = [
|
||||
makeStatus({ id: 'id-1', analysis_summary: { total: 1, counts: {}, success_rate: null } }),
|
||||
makeStatus({ id: 'id-2', analysis_summary: null }),
|
||||
];
|
||||
prisma.dailyStatus.findMany.mockResolvedValue(statuses);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/experiments/${EXP_ID}/daily-statuses?analysisOnly=false`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(2);
|
||||
});
|
||||
|
||||
// ── date filter ────────────────────────────────────────────────────────────
|
||||
|
||||
it('passes date range to Prisma when ?date= is provided', async () => {
|
||||
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
|
||||
prisma.dailyStatus.findMany.mockResolvedValue([]);
|
||||
|
||||
await request(app)
|
||||
.get(`/api/experiments/${EXP_ID}/daily-statuses?date=2026-04-23`);
|
||||
|
||||
const whereArg = prisma.dailyStatus.findMany.mock.calls[0][0].where;
|
||||
expect(whereArg.date).toBeDefined();
|
||||
expect(whereArg.date.gte).toEqual(new Date('2026-04-23T00:00:00.000Z'));
|
||||
});
|
||||
|
||||
it('returns 200 with empty array when no statuses exist', async () => {
|
||||
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
|
||||
prisma.dailyStatus.findMany.mockResolvedValue([]);
|
||||
|
||||
const res = await request(app).get(`/api/experiments/${EXP_ID}/daily-statuses`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
|
||||
it('returned records include the expected fields', async () => {
|
||||
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
|
||||
const summary = { total: 3, counts: { Success: 2 }, success_rate: 0.67 };
|
||||
prisma.dailyStatus.findMany.mockResolvedValue([
|
||||
makeStatus({ id: 'id-1', vitals: '120/80', analysis_summary: summary }),
|
||||
]);
|
||||
|
||||
const res = await request(app).get(`/api/experiments/${EXP_ID}/daily-statuses`);
|
||||
expect(res.status).toBe(200);
|
||||
const rec = res.body[0];
|
||||
expect(rec).toMatchObject({
|
||||
id: 'id-1',
|
||||
vitals: '120/80',
|
||||
analysis_summary: summary,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
const request = require('supertest');
|
||||
|
||||
// Mock Prisma before importing app so the app uses the mock
|
||||
jest.mock('../src/lib/prisma', () => require('./__mocks__/prisma'));
|
||||
const prisma = require('../src/lib/prisma');
|
||||
const app = require('../src/app');
|
||||
|
||||
const EXPERIMENT = {
|
||||
id: 'aaaaaaaa-0000-0000-0000-000000000001',
|
||||
title: 'Test Study',
|
||||
created_at: new Date('2024-01-01').toISOString(),
|
||||
_count: { animals: 0 },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /api/experiments', () => {
|
||||
it('returns list of experiments', async () => {
|
||||
prisma.experiment.findMany.mockResolvedValue([EXPERIMENT]);
|
||||
const res = await request(app).get('/api/experiments');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(1);
|
||||
expect(res.body[0].title).toBe('Test Study');
|
||||
});
|
||||
|
||||
it('returns empty array when no experiments', async () => {
|
||||
prisma.experiment.findMany.mockResolvedValue([]);
|
||||
const res = await request(app).get('/api/experiments');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/experiments/:id', () => {
|
||||
it('returns single experiment', async () => {
|
||||
prisma.experiment.findUnique.mockResolvedValue({ ...EXPERIMENT, animals: [] });
|
||||
const res = await request(app).get(`/api/experiments/${EXPERIMENT.id}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.id).toBe(EXPERIMENT.id);
|
||||
});
|
||||
|
||||
it('returns 404 for unknown id', async () => {
|
||||
prisma.experiment.findUnique.mockResolvedValue(null);
|
||||
const res = await request(app).get('/api/experiments/00000000-0000-0000-0000-000000000000');
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toMatch(/not found/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/experiments', () => {
|
||||
it('creates experiment and returns 201 with UUID', async () => {
|
||||
prisma.experiment.create.mockResolvedValue(EXPERIMENT);
|
||||
prisma.auditLog.create.mockResolvedValue({});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/experiments')
|
||||
.send({ title: 'Test Study' });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.id).toBe(EXPERIMENT.id);
|
||||
expect(res.body.title).toBe('Test Study');
|
||||
// UUID format assertion
|
||||
expect(res.body.id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
);
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ action: 'CREATE' }) })
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 422 when title is missing', async () => {
|
||||
const res = await request(app).post('/api/experiments').send({});
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.details).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ field: 'title' })])
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 422 when title exceeds 255 chars', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/experiments')
|
||||
.send({ title: 'x'.repeat(256) });
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/experiments/:id', () => {
|
||||
it('updates experiment and writes audit log', async () => {
|
||||
const updated = { ...EXPERIMENT, title: 'Renamed Study' };
|
||||
prisma.experiment.findUnique
|
||||
.mockResolvedValueOnce(EXPERIMENT) // audit middleware fetches before-state
|
||||
.mockResolvedValueOnce(updated); // audit middleware fetches after-state
|
||||
prisma.experiment.update.mockResolvedValue(updated);
|
||||
prisma.auditLog.create.mockResolvedValue({});
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/experiments/${EXPERIMENT.id}`)
|
||||
.send({ title: 'Renamed Study' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.title).toBe('Renamed Study');
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ action: 'UPDATE' }) })
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 422 with empty title', async () => {
|
||||
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
|
||||
const res = await request(app)
|
||||
.put(`/api/experiments/${EXPERIMENT.id}`)
|
||||
.send({ title: '' });
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/experiments/:id', () => {
|
||||
it('deletes experiment and writes DELETE audit log', async () => {
|
||||
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
|
||||
prisma.experiment.delete.mockResolvedValue(EXPERIMENT);
|
||||
prisma.auditLog.create.mockResolvedValue({});
|
||||
|
||||
const res = await request(app).delete(`/api/experiments/${EXPERIMENT.id}`);
|
||||
expect(res.status).toBe(204);
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ action: 'DELETE' }) })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /health', () => {
|
||||
it('returns ok status', async () => {
|
||||
const res = await request(app).get('/health');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('ok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/experiments/:id/plot-config', () => {
|
||||
const CONFIG = { xField: '__days__', tickStep: 2, hiddenSubjects: ['a1', 'a2'], sidebarOpen: true };
|
||||
|
||||
it('saves a valid config and returns it without writing an audit log', async () => {
|
||||
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
|
||||
prisma.experiment.update.mockResolvedValue({ ...EXPERIMENT, plot_config: CONFIG });
|
||||
const res = await request(app).put(`/api/experiments/${EXPERIMENT.id}/plot-config`).send(CONFIG);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual(CONFIG);
|
||||
expect(prisma.experiment.update).toHaveBeenCalledWith({
|
||||
where: { id: EXPERIMENT.id },
|
||||
data: { plot_config: CONFIG },
|
||||
});
|
||||
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 404 when the experiment does not exist', async () => {
|
||||
prisma.experiment.findUnique.mockResolvedValue(null);
|
||||
const res = await request(app).put(`/api/experiments/${EXPERIMENT.id}/plot-config`).send(CONFIG);
|
||||
expect(res.status).toBe(404);
|
||||
expect(prisma.experiment.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a non-object body with 422', async () => {
|
||||
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
|
||||
const res = await request(app).put(`/api/experiments/${EXPERIMENT.id}/plot-config`).send([1, 2, 3]);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.experiment.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an invalid hiddenSubjects with 422', async () => {
|
||||
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
|
||||
const res = await request(app).put(`/api/experiments/${EXPERIMENT.id}/plot-config`).send({ hiddenSubjects: [1, 2] });
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.experiment.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an oversized config with 422', async () => {
|
||||
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
|
||||
const big = { blob: 'x'.repeat(70000) };
|
||||
const res = await request(app).put(`/api/experiments/${EXPERIMENT.id}/plot-config`).send(big);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.experiment.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
// Global setup — runs once before all test suites.
|
||||
// When DATABASE_URL points to a real test DB, Prisma migrations are applied here.
|
||||
// In CI/local runs without a live DB, tests use mocked Prisma via jest.mock.
|
||||
module.exports = async function () {
|
||||
process.env.NODE_ENV = 'test';
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = async function () {
|
||||
// Graceful shutdown of any open handles
|
||||
};
|
||||
+13
-5
@@ -9,10 +9,13 @@ 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
|
||||
ports:
|
||||
- "5432:5432"
|
||||
# Port NOT exposed to host — only reachable by backend via internal Docker network
|
||||
expose:
|
||||
- "5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-expuser} -d ${POSTGRES_DB:-experiments_db}"]
|
||||
interval: 10s
|
||||
@@ -32,13 +35,18 @@ services:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-expuser}:${POSTGRES_PASSWORD:-exppassword}@postgres:5432/${POSTGRES_DB:-experiments_db}
|
||||
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:
|
||||
- "3001:3001"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/health || exit 1"]
|
||||
interval: 15s
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3001/health || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
retries: 10
|
||||
start_period: 40s
|
||||
|
||||
frontend:
|
||||
build:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,628 @@
|
||||
# Cross-subject Plot Interactions Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a subject-visibility sidebar, legend click-to-toggle, value-ordered tooltips, and legend/sidebar hover highlight to the "Cross-subject metrics" plot.
|
||||
|
||||
**Architecture:** Extract pure interaction logic into `src/lib/crossSubjectChart.js` (unit-tested), build a standalone presentational `SubjectSidebar` component (unit-tested with RTL), then wire shared client-side state (`hiddenSubjects`, `highlightedSubject`, `sidebarOpen`) into `ExperimentAnalysisCharts` and thread it through both Recharts `LineChart`s. Visibility is global per-subject (affects both charts).
|
||||
|
||||
**Tech Stack:** React 18, Recharts 2.15, Jest + React Testing Library, Tailwind.
|
||||
|
||||
**Constraints:** Frontend only — no backend/API/DB changes, nothing persisted. Do not alter the sibling `SubjectAnalysisCharts` or the existing controls (X axis, Color by, tick step, per-chart customize bars, Y-metric selector).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Pure interaction helpers
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/lib/crossSubjectChart.js`
|
||||
- Test: `frontend/tests/crossSubjectChart.test.js`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
```js
|
||||
// frontend/tests/crossSubjectChart.test.js
|
||||
import {
|
||||
sortPayloadByValueDesc,
|
||||
toggleInSet,
|
||||
setIdsHidden,
|
||||
groupVisibilityState,
|
||||
groupLines,
|
||||
} from '../src/lib/crossSubjectChart';
|
||||
|
||||
describe('sortPayloadByValueDesc', () => {
|
||||
it('sorts by value descending and drops null/undefined values', () => {
|
||||
const payload = [
|
||||
{ dataKey: 'a', value: 40 },
|
||||
{ dataKey: 'b', value: null },
|
||||
{ dataKey: 'c', value: 92 },
|
||||
{ dataKey: 'd', value: 78 },
|
||||
];
|
||||
expect(sortPayloadByValueDesc(payload).map((p) => p.dataKey)).toEqual(['c', 'd', 'a']);
|
||||
});
|
||||
it('returns [] for non-array input', () => {
|
||||
expect(sortPayloadByValueDesc(undefined)).toEqual([]);
|
||||
});
|
||||
it('does not mutate the input array', () => {
|
||||
const payload = [{ dataKey: 'a', value: 1 }, { dataKey: 'b', value: 2 }];
|
||||
sortPayloadByValueDesc(payload);
|
||||
expect(payload.map((p) => p.dataKey)).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleInSet', () => {
|
||||
it('adds a missing id and removes a present id, returning a new Set', () => {
|
||||
const a = new Set(['x']);
|
||||
const added = toggleInSet(a, 'y');
|
||||
expect([...added].sort()).toEqual(['x', 'y']);
|
||||
expect(added).not.toBe(a);
|
||||
const removed = toggleInSet(added, 'x');
|
||||
expect([...removed]).toEqual(['y']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setIdsHidden', () => {
|
||||
it('adds all ids when hidden=true', () => {
|
||||
expect([...setIdsHidden(new Set(['a']), ['b', 'c'], true)].sort()).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
it('removes all ids when hidden=false', () => {
|
||||
expect([...setIdsHidden(new Set(['a', 'b', 'c']), ['b', 'c'], false)]).toEqual(['a']);
|
||||
});
|
||||
it('returns a new Set', () => {
|
||||
const s = new Set();
|
||||
expect(setIdsHidden(s, ['a'], true)).not.toBe(s);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupVisibilityState', () => {
|
||||
it("returns 'all' when none of the ids are hidden", () => {
|
||||
expect(groupVisibilityState(['a', 'b'], new Set())).toBe('all');
|
||||
});
|
||||
it("returns 'none' when all ids are hidden", () => {
|
||||
expect(groupVisibilityState(['a', 'b'], new Set(['a', 'b']))).toBe('none');
|
||||
});
|
||||
it("returns 'some' when a subset is hidden", () => {
|
||||
expect(groupVisibilityState(['a', 'b'], new Set(['a']))).toBe('some');
|
||||
});
|
||||
it("returns 'none' for an empty group", () => {
|
||||
expect(groupVisibilityState([], new Set())).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupLines', () => {
|
||||
it('groups by .group preserving first-seen group order and within-group order', () => {
|
||||
const lines = [
|
||||
{ id: '1', group: 'B' },
|
||||
{ id: '2', group: 'A' },
|
||||
{ id: '3', group: 'B' },
|
||||
];
|
||||
const out = groupLines(lines);
|
||||
expect(out.map((g) => g.group)).toEqual(['B', 'A']);
|
||||
expect(out[0].subjects.map((s) => s.id)).toEqual(['1', '3']);
|
||||
});
|
||||
it("uses '—' for lines with no group", () => {
|
||||
expect(groupLines([{ id: '1' }])[0].group).toBe('—');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cd frontend && npx jest tests/crossSubjectChart.test.js`
|
||||
Expected: FAIL — cannot find module `../src/lib/crossSubjectChart`.
|
||||
|
||||
- [ ] **Step 3: Write the implementation**
|
||||
|
||||
```js
|
||||
// frontend/src/lib/crossSubjectChart.js
|
||||
// Pure helpers for the cross-subject metrics plot interactions. No React, no I/O.
|
||||
|
||||
// Sort a Recharts tooltip payload by numeric value descending, dropping entries
|
||||
// whose value is null/undefined. Returns a NEW array (does not mutate input).
|
||||
export function sortPayloadByValueDesc(payload) {
|
||||
if (!Array.isArray(payload)) return [];
|
||||
return payload
|
||||
.filter((p) => p && p.value != null)
|
||||
.slice()
|
||||
.sort((a, b) => b.value - a.value);
|
||||
}
|
||||
|
||||
// Toggle an id's membership in a Set, returning a NEW Set.
|
||||
export function toggleInSet(set, id) {
|
||||
const next = new Set(set);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
}
|
||||
|
||||
// Add (hidden=true) or remove (hidden=false) a list of ids from a hidden-set.
|
||||
// Returns a NEW Set.
|
||||
export function setIdsHidden(set, ids, hidden) {
|
||||
const next = new Set(set);
|
||||
for (const id of ids) {
|
||||
if (hidden) next.add(id);
|
||||
else next.delete(id);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
// Tri-state for a group checkbox given which subject ids are hidden.
|
||||
// 'all' = all visible, 'none' = all hidden, 'some' = mixed.
|
||||
export function groupVisibilityState(subjectIds, hiddenSet) {
|
||||
if (subjectIds.length === 0) return 'none';
|
||||
const hiddenCount = subjectIds.filter((id) => hiddenSet.has(id)).length;
|
||||
if (hiddenCount === 0) return 'all';
|
||||
if (hiddenCount === subjectIds.length) return 'none';
|
||||
return 'some';
|
||||
}
|
||||
|
||||
// Group chart "lines" by their .group field, preserving first-seen group order
|
||||
// and line order within each group. Returns [{ group, subjects: [line...] }].
|
||||
export function groupLines(lines) {
|
||||
const order = [];
|
||||
const byGroup = new Map();
|
||||
for (const line of lines) {
|
||||
const g = line.group ?? '—';
|
||||
if (!byGroup.has(g)) { byGroup.set(g, []); order.push(g); }
|
||||
byGroup.get(g).push(line);
|
||||
}
|
||||
return order.map((group) => ({ group, subjects: byGroup.get(group) }));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd frontend && npx jest tests/crossSubjectChart.test.js`
|
||||
Expected: PASS — all describe blocks green.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/lib/crossSubjectChart.js frontend/tests/crossSubjectChart.test.js
|
||||
git commit -m "feat(frontend): pure helpers for cross-subject plot interactions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: SubjectSidebar component
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/SubjectSidebar.jsx`
|
||||
- Test: `frontend/tests/SubjectSidebar.test.jsx`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
```jsx
|
||||
// frontend/tests/SubjectSidebar.test.jsx
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, within } from '@testing-library/react';
|
||||
import { SubjectSidebar } from '../src/components/SubjectSidebar';
|
||||
|
||||
const LINES = [
|
||||
{ id: 'a1', name: 'Mouse-A1', group: 'Group A', color: '#111' },
|
||||
{ id: 'a2', name: 'Mouse-A2', group: 'Group A', color: '#222' },
|
||||
{ id: 'b1', name: 'Mouse-B1', group: 'Group B', color: '#333' },
|
||||
];
|
||||
|
||||
function setup(overrides = {}) {
|
||||
const props = {
|
||||
lines: LINES,
|
||||
hiddenSubjects: new Set(),
|
||||
onToggleSubject: jest.fn(),
|
||||
onToggleGroup: jest.fn(),
|
||||
onShowAll: jest.fn(),
|
||||
onHighlight: jest.fn(),
|
||||
...overrides,
|
||||
};
|
||||
render(<SubjectSidebar {...props} />);
|
||||
return props;
|
||||
}
|
||||
|
||||
it('renders each group header with its subject count', () => {
|
||||
setup();
|
||||
expect(screen.getByText('Group A')).toBeInTheDocument();
|
||||
expect(screen.getByText('Group B')).toBeInTheDocument();
|
||||
expect(screen.getByText('(2)')).toBeInTheDocument();
|
||||
expect(screen.getByText('(1)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a subject checkbox per subject, checked when visible', () => {
|
||||
setup({ hiddenSubjects: new Set(['a2']) });
|
||||
expect(screen.getByLabelText('Mouse-A1').checked).toBe(true);
|
||||
expect(screen.getByLabelText('Mouse-A2').checked).toBe(false);
|
||||
});
|
||||
|
||||
it('calls onToggleSubject with the id when a subject checkbox is clicked', () => {
|
||||
const { onToggleSubject } = setup();
|
||||
fireEvent.click(screen.getByLabelText('Mouse-A1'));
|
||||
expect(onToggleSubject).toHaveBeenCalledWith('a1');
|
||||
});
|
||||
|
||||
it('shows the group checkbox as indeterminate when the group is mixed', () => {
|
||||
setup({ hiddenSubjects: new Set(['a1']) });
|
||||
const groupCheckbox = screen.getByLabelText(/Group A/);
|
||||
expect(groupCheckbox.indeterminate).toBe(true);
|
||||
expect(groupCheckbox.checked).toBe(false);
|
||||
});
|
||||
|
||||
it('checks the group checkbox when all members are visible', () => {
|
||||
setup();
|
||||
expect(screen.getByLabelText(/Group A/).checked).toBe(true);
|
||||
});
|
||||
|
||||
it('calls onToggleGroup with the group ids and hidden=true when an all-visible group is clicked', () => {
|
||||
const { onToggleGroup } = setup();
|
||||
fireEvent.click(screen.getByLabelText(/Group A/));
|
||||
expect(onToggleGroup).toHaveBeenCalledWith(['a1', 'a2'], true);
|
||||
});
|
||||
|
||||
it('hides the Show all link when nothing is hidden, shows it otherwise', () => {
|
||||
const { rerender } = render(
|
||||
<SubjectSidebar lines={LINES} hiddenSubjects={new Set()}
|
||||
onToggleSubject={() => {}} onToggleGroup={() => {}}
|
||||
onShowAll={() => {}} onHighlight={() => {}} />,
|
||||
);
|
||||
expect(screen.queryByText('Show all')).toBeNull();
|
||||
rerender(
|
||||
<SubjectSidebar lines={LINES} hiddenSubjects={new Set(['a1'])}
|
||||
onToggleSubject={() => {}} onToggleGroup={() => {}}
|
||||
onShowAll={() => {}} onHighlight={() => {}} />,
|
||||
);
|
||||
expect(screen.getByText('Show all')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onShowAll when the Show all link is clicked', () => {
|
||||
const { onShowAll } = setup({ hiddenSubjects: new Set(['a1']) });
|
||||
fireEvent.click(screen.getByText('Show all'));
|
||||
expect(onShowAll).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onHighlight on subject row hover enter/leave', () => {
|
||||
const { onHighlight } = setup();
|
||||
const row = screen.getByLabelText('Mouse-A1').closest('label');
|
||||
fireEvent.mouseEnter(row);
|
||||
expect(onHighlight).toHaveBeenCalledWith('a1');
|
||||
fireEvent.mouseLeave(row);
|
||||
expect(onHighlight).toHaveBeenCalledWith(null);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cd frontend && npx jest tests/SubjectSidebar.test.jsx`
|
||||
Expected: FAIL — cannot find module `../src/components/SubjectSidebar`.
|
||||
|
||||
- [ ] **Step 3: Write the implementation**
|
||||
|
||||
Note: the group checkbox's accessible name comes from the group `<span>` text inside its `<label>`, so `getByLabelText(/Group A/)` matches it; subject checkboxes are matched by their name text. The group `<label>` text includes the count, so its accessible name is e.g. "Group A (2)" — `/Group A/` regex still matches.
|
||||
|
||||
```jsx
|
||||
// frontend/src/components/SubjectSidebar.jsx
|
||||
import React from 'react';
|
||||
import { groupLines, groupVisibilityState } from '../lib/crossSubjectChart';
|
||||
|
||||
// Presentational panel listing subjects grouped by their group, with per-subject
|
||||
// and per-group visibility checkboxes. All state lives in the parent; this component
|
||||
// only renders and calls handlers.
|
||||
export function SubjectSidebar({
|
||||
lines, hiddenSubjects, onToggleSubject, onToggleGroup, onShowAll, onHighlight,
|
||||
}) {
|
||||
const groups = groupLines(lines);
|
||||
const anyHidden = lines.some((l) => hiddenSubjects.has(l.id));
|
||||
|
||||
return (
|
||||
<div className="w-[210px] shrink-0 border-l border-gray-100 pl-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Subjects</span>
|
||||
{anyHidden && (
|
||||
<button type="button" onClick={onShowAll}
|
||||
className="text-xs text-indigo-500 hover:text-indigo-700">Show all</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3 max-h-[320px] overflow-y-auto pr-1">
|
||||
{groups.map(({ group, subjects }) => {
|
||||
const ids = subjects.map((s) => s.id);
|
||||
const state = groupVisibilityState(ids, hiddenSubjects);
|
||||
return (
|
||||
<div key={group}>
|
||||
<label className="flex items-center gap-1.5 text-xs font-medium text-gray-600 cursor-pointer">
|
||||
<input type="checkbox"
|
||||
checked={state === 'all'}
|
||||
ref={(el) => { if (el) el.indeterminate = state === 'some'; }}
|
||||
onChange={() => onToggleGroup(ids, state === 'all')}
|
||||
className="accent-indigo-500" />
|
||||
<span className="truncate">{group}</span>
|
||||
<span className="text-gray-400">({subjects.length})</span>
|
||||
</label>
|
||||
<div className="mt-1 space-y-0.5 pl-1">
|
||||
{subjects.map((s) => (
|
||||
<label key={s.id}
|
||||
onMouseEnter={() => onHighlight(s.id)}
|
||||
onMouseLeave={() => onHighlight(null)}
|
||||
className="flex items-center gap-1.5 text-xs text-gray-600 cursor-pointer hover:bg-gray-50 rounded px-1 py-0.5">
|
||||
<input type="checkbox"
|
||||
checked={!hiddenSubjects.has(s.id)}
|
||||
onChange={() => onToggleSubject(s.id)}
|
||||
className="accent-indigo-500" />
|
||||
<span className="inline-block w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: s.color }} />
|
||||
<span className="truncate">{s.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd frontend && npx jest tests/SubjectSidebar.test.jsx`
|
||||
Expected: PASS — all tests green.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/SubjectSidebar.jsx frontend/tests/SubjectSidebar.test.jsx
|
||||
git commit -m "feat(frontend): add SubjectSidebar visibility panel"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Wire state, sidebar, and behaviors into ExperimentAnalysisCharts
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/AnalysisCharts.jsx` (`ExperimentAnalysisCharts`, lines ~446–641; imports at top)
|
||||
- Test: `frontend/tests/ExperimentAnalysisCharts.test.jsx` (create)
|
||||
|
||||
- [ ] **Step 1: Add imports**
|
||||
|
||||
At the top of `frontend/src/components/AnalysisCharts.jsx`, add after the existing `date-fns` import:
|
||||
|
||||
```jsx
|
||||
import { SubjectSidebar } from './SubjectSidebar';
|
||||
import { sortPayloadByValueDesc, toggleInSet, setIdsHidden } from '../lib/crossSubjectChart';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add shared state and handlers**
|
||||
|
||||
In `ExperimentAnalysisCharts`, immediately after the existing `const [tickStep, setTickStep] = useState(1);` line (~454), add:
|
||||
|
||||
```jsx
|
||||
// Cross-subject interaction state (client-only; global across both charts)
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [hiddenSubjects, setHiddenSubjects] = useState(() => new Set());
|
||||
const [highlightedSubject, setHighlightedSubject] = useState(null);
|
||||
|
||||
const toggleHidden = (id) => setHiddenSubjects((prev) => toggleInSet(prev, id));
|
||||
const toggleGroup = (ids, hidden) => setHiddenSubjects((prev) => setIdsHidden(prev, ids, hidden));
|
||||
const showAll = () => setHiddenSubjects(new Set());
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update the `ExpTooltip` to order rows by value descending**
|
||||
|
||||
Replace the existing `ExpTooltip` function (~518–533) with:
|
||||
|
||||
```jsx
|
||||
function ExpTooltip({ active, payload, label, unit = '' }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const rows = sortPayloadByValueDesc(payload);
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded shadow-sm px-3 py-2 text-xs space-y-0.5 max-h-48 overflow-y-auto">
|
||||
<p className="font-semibold text-gray-700 mb-1">{label}</p>
|
||||
{rows.map((p) => {
|
||||
const line = lines.find((l) => l.id === p.dataKey);
|
||||
return (
|
||||
<p key={p.dataKey} style={{ color: p.stroke }}>
|
||||
{line?.name ?? p.dataKey}{line?.group && line.group !== '—' ? ` (${line.group})` : ''}: {p.value}{unit}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the sidebar toggle button to the global controls row**
|
||||
|
||||
In the global-controls `<div className="flex items-center flex-wrap gap-3">` block, immediately after the `<TickStepControl ... />` line (~556), add:
|
||||
|
||||
```jsx
|
||||
{hasData && (
|
||||
<button type="button" onClick={() => setSidebarOpen((o) => !o)}
|
||||
className={[
|
||||
'text-xs px-2 py-0.5 rounded border transition-colors ml-auto',
|
||||
sidebarOpen
|
||||
? 'bg-gray-100 border-gray-300 text-gray-700'
|
||||
: 'bg-white border-gray-200 text-gray-500 hover:text-gray-700 hover:border-gray-300',
|
||||
].join(' ')}>
|
||||
{sidebarOpen ? '◂ Subjects' : '▸ Subjects'}
|
||||
</button>
|
||||
)}
|
||||
```
|
||||
|
||||
Note: `hasData` is declared at ~509 (`const hasData = lines.length > 0;`), before the `return`, so it is in scope here.
|
||||
|
||||
- [ ] **Step 5: Wrap the charts in a flex row with the sidebar**
|
||||
|
||||
Replace the `{hasData && (` fragment opener and its closing for the charts block. The current structure is:
|
||||
|
||||
```jsx
|
||||
{hasData && (
|
||||
<>
|
||||
{/* Chart 1 — counts */}
|
||||
<div> ... </div>
|
||||
{/* Chart 2 — success rate */}
|
||||
<div> ... </div>
|
||||
</>
|
||||
)}
|
||||
```
|
||||
|
||||
Change ONLY the wrapper (keep both chart `<div>` blocks exactly as they are between the markers):
|
||||
|
||||
```jsx
|
||||
{hasData && (
|
||||
<div className="flex gap-3 items-start">
|
||||
<div className="flex-1 min-w-0 space-y-5">
|
||||
{/* Chart 1 — counts */}
|
||||
{/* ...unchanged chart 1 <div> ... */}
|
||||
{/* Chart 2 — success rate */}
|
||||
{/* ...unchanged chart 2 <div> ... */}
|
||||
</div>
|
||||
{sidebarOpen && (
|
||||
<SubjectSidebar
|
||||
lines={lines}
|
||||
hiddenSubjects={hiddenSubjects}
|
||||
onToggleSubject={toggleHidden}
|
||||
onToggleGroup={toggleGroup}
|
||||
onShowAll={showAll}
|
||||
onHighlight={setHighlightedSubject}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
`min-w-0` on the chart column lets the flex child shrink so `ResponsiveContainer` re-flows when the sidebar is open.
|
||||
|
||||
- [ ] **Step 6: Add legend handlers and per-line hide/highlight to BOTH charts**
|
||||
|
||||
In the **counts** chart, replace the `<Legend ... />` (~602–603) with:
|
||||
|
||||
```jsx
|
||||
<Legend wrapperStyle={{ fontSize: 11 }}
|
||||
formatter={(value) => lines.find((l) => l.id === value)?.name ?? value}
|
||||
onClick={(o) => toggleHidden(o.dataKey)}
|
||||
onMouseEnter={(o) => setHighlightedSubject(o.dataKey)}
|
||||
onMouseLeave={() => setHighlightedSubject(null)} />
|
||||
```
|
||||
|
||||
and replace its `lines.map(...)` `<Line>` block (~604–608) with:
|
||||
|
||||
```jsx
|
||||
{lines.map((l) => (
|
||||
<Line key={l.id} type="monotone" dataKey={l.id} name={l.id}
|
||||
stroke={l.color}
|
||||
strokeWidth={highlightedSubject === l.id ? 3.5 : 2}
|
||||
hide={hiddenSubjects.has(l.id)}
|
||||
dot={{ r: 3 }} activeDot={{ r: 5 }}
|
||||
connectNulls isAnimationActive={false} />
|
||||
))}
|
||||
```
|
||||
|
||||
In the **success-rate** chart, make the identical replacements for its `<Legend ... />` (~627–628) and its `lines.map(...)` `<Line>` block (~629–633) — same two snippets above.
|
||||
|
||||
- [ ] **Step 7: Write a smoke test for the wiring**
|
||||
|
||||
```jsx
|
||||
// frontend/tests/ExperimentAnalysisCharts.test.jsx
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
|
||||
// recharts uses ResizeObserver (absent in jsdom); stub all used pieces as passthroughs.
|
||||
jest.mock('recharts', () => {
|
||||
const Pass = ({ children }) => <div>{children}</div>;
|
||||
return {
|
||||
ResponsiveContainer: Pass, LineChart: Pass, Line: () => null,
|
||||
XAxis: () => null, YAxis: () => null, CartesianGrid: () => null,
|
||||
Tooltip: () => null, Legend: () => null,
|
||||
};
|
||||
});
|
||||
|
||||
import { ExperimentAnalysisCharts } from '../src/components/AnalysisCharts';
|
||||
|
||||
const ANIMALS = [
|
||||
{ id: 'a1', animal_name: 'Mouse-A1', subject_info: { grp: 'Group A' } },
|
||||
{ id: 'b1', animal_name: 'Mouse-B1', subject_info: { grp: 'Group B' } },
|
||||
];
|
||||
const STATUSES = [
|
||||
{ animal_id: 'a1', date: '2026-01-01', analysis_summary: { total: 10, success_rate: 0.5, counts: { Success: 5 } } },
|
||||
{ animal_id: 'b1', date: '2026-01-01', analysis_summary: { total: 8, success_rate: 0.25, counts: { Success: 2 } } },
|
||||
];
|
||||
const SUBJECT_TEMPLATE = [{ fieldId: 'grp', label: 'Group', active: true }];
|
||||
const DAILY_TEMPLATE = [];
|
||||
|
||||
function renderChart() {
|
||||
render(
|
||||
<ExperimentAnalysisCharts
|
||||
animals={ANIMALS}
|
||||
experimentStatuses={STATUSES}
|
||||
subjectTemplate={SUBJECT_TEMPLATE}
|
||||
dailyTemplate={DAILY_TEMPLATE}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it('toggles the subject sidebar open and closed', () => {
|
||||
renderChart();
|
||||
expect(screen.queryByText('Subjects')).toBeNull(); // closed: panel header not rendered
|
||||
fireEvent.click(screen.getByText('▸ Subjects'));
|
||||
// panel header "Subjects" + both subjects now visible
|
||||
expect(screen.getByText('Subjects')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Mouse-A1')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Mouse-B1')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('◂ Subjects'));
|
||||
expect(screen.queryByText('Subjects')).toBeNull();
|
||||
});
|
||||
|
||||
it('unchecking a subject in the sidebar persists across re-render', () => {
|
||||
renderChart();
|
||||
fireEvent.click(screen.getByText('▸ Subjects'));
|
||||
const cb = screen.getByLabelText('Mouse-A1');
|
||||
expect(cb.checked).toBe(true);
|
||||
fireEvent.click(cb);
|
||||
expect(screen.getByLabelText('Mouse-A1').checked).toBe(false);
|
||||
// hiding one subject reveals the Show all link
|
||||
expect(screen.getByText('Show all')).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
|
||||
Note: the controls header `<h2>` text is "Cross-subject metrics"; the sidebar panel header text is "Subjects". They are distinct, so `queryByText('Subjects')` is null until the panel opens.
|
||||
|
||||
- [ ] **Step 8: Run the smoke test to verify it passes**
|
||||
|
||||
Run: `cd frontend && npx jest tests/ExperimentAnalysisCharts.test.jsx`
|
||||
Expected: PASS — both tests green.
|
||||
|
||||
- [ ] **Step 9: Run the full frontend test suite to confirm no regressions**
|
||||
|
||||
Run: `cd frontend && npm test`
|
||||
Expected: PASS — all existing suites plus the three new files green.
|
||||
|
||||
- [ ] **Step 10: Build to confirm no syntax/compile errors**
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
Expected: Vite build succeeds with no errors.
|
||||
|
||||
- [ ] **Step 11: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/AnalysisCharts.jsx frontend/tests/ExperimentAnalysisCharts.test.jsx
|
||||
git commit -m "feat(frontend): subject sidebar, legend toggle/highlight, ordered tooltip on cross-subject plot"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Manual Verification (after Task 3)
|
||||
|
||||
On the `ExperimentDetail` page for an experiment with animals and saved analysis metrics:
|
||||
|
||||
- [ ] `▸ Subjects` button toggles the sidebar; charts re-flow narrower while open.
|
||||
- [ ] Unchecking a subject removes its line from BOTH charts; its legend entry greys out and stays clickable.
|
||||
- [ ] Re-checking (sidebar) or clicking the greyed legend entry restores the line in both charts.
|
||||
- [ ] Group checkbox toggles all members; shows indeterminate (–) when the group is mixed.
|
||||
- [ ] "Show all" appears only when something is hidden and clears all hidden state.
|
||||
- [ ] Clicking a legend entry in either chart toggles that subject in both.
|
||||
- [ ] Hover tooltip lists subjects highest-value-first, matching their vertical order.
|
||||
- [ ] Hovering a legend entry OR a sidebar row thickens that subject's line in both charts; leaving restores it.
|
||||
- [ ] Existing controls still work: X axis, Color by, tick step, per-chart customize (size/Y range/X zoom/X-field), Y-metric selector. Sibling "Session metrics over time" chart is unchanged.
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
- **Spec coverage:** sidebar + groups + per-subject/per-group checkboxes (Task 2, Task 3 wiring); global visibility via `hiddenSubjects` (Task 1 `toggleInSet`/`setIdsHidden`, Task 3); legend click toggle (Task 3 Step 6); value-ordered tooltip (Task 1 `sortPayloadByValueDesc`, Task 3 Step 3); legend + sidebar hover highlight via `highlightedSubject` + `strokeWidth` (Task 2 onHighlight, Task 3 Steps 2/6). All four behaviors covered.
|
||||
- **Naming consistency:** `toggleHidden`, `toggleGroup`, `showAll`, `setHighlightedSubject`, `hiddenSubjects`, `highlightedSubject`, `sidebarOpen` used identically across Task 2 props and Task 3 wiring.
|
||||
- **No DB/API:** all state is `useState` in the component; no imports from `api/`.
|
||||
@@ -0,0 +1,678 @@
|
||||
# Persist Cross-subject Plot Config Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Persist the "Cross-subject metrics" plot's display settings per experiment in the database, hydrating on load and auto-saving (debounced) on change.
|
||||
|
||||
**Architecture:** Add a nullable `plot_config` JSONB column to `experiments` (Prisma). Expose `PUT /api/experiments/:id/plot-config` (validated, no audit log). On the frontend, pure helpers (`serializePlotConfig`/`readPlotConfig`) translate between React state and the stored blob; `ExperimentAnalysisCharts` initializes its state from the saved config and debounce-saves changes.
|
||||
|
||||
**Tech Stack:** Postgres + Prisma 5, Express + express-validator, Jest + supertest (backend), React 18 + Recharts 2.15, Jest + React Testing Library (frontend).
|
||||
|
||||
**Constraints:** Follow the existing `subject_info_template` route/migration pattern. No audit-log writes for plot-config. Do not change `SubjectAnalysisCharts`, the template/subject-template routes, or any existing behavior. Migration is additive and nullable — existing experiments load with defaults.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend — column, migration, route, validator, tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/prisma/schema.prisma` (model `Experiment`)
|
||||
- Create: `backend/prisma/migrations/20260601000000_add_experiment_plot_config/migration.sql`
|
||||
- Modify: `backend/src/routes/experiments.js` (add validator near other validators; add route after the `PUT /:id/subject-template` handler, ~line 309)
|
||||
- Test: `backend/tests/experiments.test.js` (add a describe block)
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add this block to the end of `backend/tests/experiments.test.js` (it uses the file's existing `request`, `prisma` mock, `EXPERIMENT`, and `beforeEach(jest.clearAllMocks)`):
|
||||
|
||||
```js
|
||||
describe('PUT /api/experiments/:id/plot-config', () => {
|
||||
const CONFIG = { xField: '__days__', tickStep: 2, hiddenSubjects: ['a1', 'a2'], sidebarOpen: true };
|
||||
|
||||
it('saves a valid config and returns it without writing an audit log', async () => {
|
||||
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
|
||||
prisma.experiment.update.mockResolvedValue({ ...EXPERIMENT, plot_config: CONFIG });
|
||||
const res = await request(app).put(`/api/experiments/${EXPERIMENT.id}/plot-config`).send(CONFIG);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual(CONFIG);
|
||||
expect(prisma.experiment.update).toHaveBeenCalledWith({
|
||||
where: { id: EXPERIMENT.id },
|
||||
data: { plot_config: CONFIG },
|
||||
});
|
||||
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 404 when the experiment does not exist', async () => {
|
||||
prisma.experiment.findUnique.mockResolvedValue(null);
|
||||
const res = await request(app).put(`/api/experiments/${EXPERIMENT.id}/plot-config`).send(CONFIG);
|
||||
expect(res.status).toBe(404);
|
||||
expect(prisma.experiment.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a non-object body with 422', async () => {
|
||||
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
|
||||
const res = await request(app).put(`/api/experiments/${EXPERIMENT.id}/plot-config`).send([1, 2, 3]);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.experiment.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an invalid hiddenSubjects with 422', async () => {
|
||||
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
|
||||
const res = await request(app).put(`/api/experiments/${EXPERIMENT.id}/plot-config`).send({ hiddenSubjects: [1, 2] });
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.experiment.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an oversized config with 422', async () => {
|
||||
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
|
||||
const big = { blob: 'x'.repeat(20000) };
|
||||
const res = await request(app).put(`/api/experiments/${EXPERIMENT.id}/plot-config`).send(big);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.experiment.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `cd backend && npx jest tests/experiments.test.js -t "plot-config"`
|
||||
Expected: FAIL — route returns 404/route not found (no handler yet), assertions fail.
|
||||
|
||||
- [ ] **Step 3: Add the Prisma column**
|
||||
|
||||
In `backend/prisma/schema.prisma`, in `model Experiment`, add a `plot_config` field (place it after `subject_info_template`):
|
||||
|
||||
```prisma
|
||||
model Experiment {
|
||||
id String @id @default(uuid())
|
||||
title String
|
||||
created_at DateTime @default(now())
|
||||
template Json @default("[]")
|
||||
subject_info_template Json @default("[]")
|
||||
plot_config Json?
|
||||
animals Animal[]
|
||||
|
||||
@@map("experiments")
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Create the migration file**
|
||||
|
||||
Create `backend/prisma/migrations/20260601000000_add_experiment_plot_config/migration.sql`:
|
||||
|
||||
```sql
|
||||
-- AlterTable
|
||||
ALTER TABLE "experiments" ADD COLUMN "plot_config" JSONB;
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Regenerate the Prisma client**
|
||||
|
||||
Run: `cd backend && npx prisma generate`
|
||||
Expected: "Generated Prisma Client" with no errors (no DB connection required).
|
||||
|
||||
- [ ] **Step 6: Add the validator and route**
|
||||
|
||||
In `backend/src/routes/experiments.js`, add this validator function alongside the other validators (e.g. right after `validateSubjectTemplate`, before the routes that use it):
|
||||
|
||||
```js
|
||||
function validatePlotConfig(config) {
|
||||
if (config === null || typeof config !== 'object' || Array.isArray(config)) {
|
||||
return 'plot_config must be an object';
|
||||
}
|
||||
let size;
|
||||
try { size = JSON.stringify(config).length; } catch { return 'plot_config must be serializable'; }
|
||||
if (size > 16384) return 'plot_config too large';
|
||||
if ('hiddenSubjects' in config) {
|
||||
const h = config.hiddenSubjects;
|
||||
if (!Array.isArray(h) || h.length > 500 || !h.every((x) => typeof x === 'string')) {
|
||||
return 'hiddenSubjects invalid';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
Then add this route immediately after the `PUT /:id/subject-template` handler (after its closing `});`, ~line 309):
|
||||
|
||||
```js
|
||||
// PUT /api/experiments/:id/plot-config — persist UI display settings (no audit log)
|
||||
router.put('/:id/plot-config', 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 config = req.body;
|
||||
const validationError = validatePlotConfig(config);
|
||||
if (validationError) return res.status(422).json({ error: validationError });
|
||||
|
||||
await prisma.experiment.update({
|
||||
where: { id: req.params.id },
|
||||
data: { plot_config: config },
|
||||
});
|
||||
res.json(config);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Run the tests to verify they pass**
|
||||
|
||||
Run: `cd backend && npx jest tests/experiments.test.js -t "plot-config"`
|
||||
Expected: PASS — all 5 new tests green.
|
||||
|
||||
- [ ] **Step 8: Run the full backend suite (no regressions)**
|
||||
|
||||
Run: `cd backend && npm test`
|
||||
Expected: PASS — all suites green (note any pre-existing failures unrelated to this change).
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/prisma/schema.prisma backend/prisma/migrations/20260601000000_add_experiment_plot_config backend/src/routes/experiments.js backend/tests/experiments.test.js
|
||||
git commit -m "feat(backend): persist per-experiment plot_config via PUT /:id/plot-config"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Frontend — serialize/read pure helpers
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/lib/crossSubjectChart.js` (append two functions + two private helpers)
|
||||
- Test: `frontend/tests/crossSubjectChart.test.js` (append describe blocks)
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `frontend/tests/crossSubjectChart.test.js`. First add the two new names to the existing import at the top of the file — change the import to also include `serializePlotConfig, readPlotConfig`:
|
||||
|
||||
```js
|
||||
import {
|
||||
sortPayloadByValueDesc,
|
||||
toggleInSet,
|
||||
setIdsHidden,
|
||||
groupVisibilityState,
|
||||
groupLines,
|
||||
serializePlotConfig,
|
||||
readPlotConfig,
|
||||
} from '../src/lib/crossSubjectChart';
|
||||
```
|
||||
|
||||
Then append these describe blocks at the end of the file:
|
||||
|
||||
```js
|
||||
describe('serializePlotConfig', () => {
|
||||
const STATE = {
|
||||
xField: '__days__', groupBy: 'g1', tickStep: 2, metric: 'Success',
|
||||
hiddenSubjects: new Set(['b', 'a']), sidebarOpen: true,
|
||||
counts: { height: 220, yMin: '0', yMax: '', xZoomMin: '', xZoomMax: '', xFieldOverride: 'f2' },
|
||||
rate: { height: 160, yMin: '', yMax: '', xZoomMin: '', xZoomMax: '', xFieldOverride: null },
|
||||
};
|
||||
|
||||
it('produces a plain JSON object with hiddenSubjects as a sorted array', () => {
|
||||
const out = serializePlotConfig(STATE);
|
||||
expect(out.hiddenSubjects).toEqual(['a', 'b']);
|
||||
expect(out.sidebarOpen).toBe(true);
|
||||
expect(out.xField).toBe('__days__');
|
||||
expect(out.metric).toBe('Success');
|
||||
expect(out.counts).toEqual({ height: 220, yMin: '0', yMax: '', xZoomMin: '', xZoomMax: '', xFieldOverride: 'f2' });
|
||||
expect(out.rate.xFieldOverride).toBeNull();
|
||||
});
|
||||
|
||||
it('round-trips through readPlotConfig (values preserved)', () => {
|
||||
const back = readPlotConfig(serializePlotConfig(STATE));
|
||||
expect(back.xField).toBe('__days__');
|
||||
expect(back.groupBy).toBe('g1');
|
||||
expect(back.tickStep).toBe(2);
|
||||
expect(back.hiddenSubjects).toEqual(['a', 'b']);
|
||||
expect(back.sidebarOpen).toBe(true);
|
||||
expect(back.counts.height).toBe(220);
|
||||
expect(back.counts.xFieldOverride).toBe('f2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readPlotConfig', () => {
|
||||
it('returns safe defaults for null', () => {
|
||||
const c = readPlotConfig(null);
|
||||
expect(c.hiddenSubjects).toEqual([]);
|
||||
expect(c.sidebarOpen).toBe(false);
|
||||
expect(c.xField).toBeUndefined();
|
||||
expect(c.counts.yMin).toBe('');
|
||||
expect(c.counts.xFieldOverride).toBeNull();
|
||||
});
|
||||
|
||||
it('returns safe defaults for a non-object', () => {
|
||||
expect(readPlotConfig([1, 2]).hiddenSubjects).toEqual([]);
|
||||
expect(readPlotConfig('nope').sidebarOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('filters non-string hiddenSubjects and ignores wrong-typed fields', () => {
|
||||
const c = readPlotConfig({ hiddenSubjects: ['a', 5, 'b'], tickStep: 'x', sidebarOpen: 'yes' });
|
||||
expect(c.hiddenSubjects).toEqual(['a', 'b']);
|
||||
expect(c.tickStep).toBeUndefined();
|
||||
expect(c.sidebarOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('does not throw on malformed nested chart settings', () => {
|
||||
expect(() => readPlotConfig({ counts: 'bad', rate: [] })).not.toThrow();
|
||||
const c = readPlotConfig({ counts: 'bad' });
|
||||
expect(c.counts.yMin).toBe('');
|
||||
expect(c.counts.height).toBeUndefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `cd frontend && npx jest tests/crossSubjectChart.test.js`
|
||||
Expected: FAIL — `serializePlotConfig`/`readPlotConfig` are not exported.
|
||||
|
||||
- [ ] **Step 3: Implement the helpers**
|
||||
|
||||
Append to `frontend/src/lib/crossSubjectChart.js`:
|
||||
|
||||
```js
|
||||
// ── Plot config (de)serialization ───────────────────────────────────────────────
|
||||
// Translate between ExperimentAnalysisCharts React state and the stored JSONB blob.
|
||||
// readPlotConfig is defensive: it never throws and fills safe defaults for missing
|
||||
// or malformed input (unknown keys are ignored).
|
||||
|
||||
function snapshotChart(s = {}) {
|
||||
return {
|
||||
height: s.height,
|
||||
yMin: s.yMin ?? '',
|
||||
yMax: s.yMax ?? '',
|
||||
xZoomMin: s.xZoomMin ?? '',
|
||||
xZoomMax: s.xZoomMax ?? '',
|
||||
xFieldOverride: s.xFieldOverride ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializePlotConfig(state) {
|
||||
const { xField, groupBy, tickStep, metric, hiddenSubjects, sidebarOpen, counts, rate } = state;
|
||||
return {
|
||||
xField,
|
||||
groupBy,
|
||||
tickStep,
|
||||
metric,
|
||||
hiddenSubjects: [...(hiddenSubjects ?? [])].sort(),
|
||||
sidebarOpen: !!sidebarOpen,
|
||||
counts: snapshotChart(counts),
|
||||
rate: snapshotChart(rate),
|
||||
};
|
||||
}
|
||||
|
||||
function readChart(c) {
|
||||
const o = c && typeof c === 'object' && !Array.isArray(c) ? c : {};
|
||||
const str = (v) => (typeof v === 'string' ? v : '');
|
||||
return {
|
||||
height: Number.isFinite(o.height) ? o.height : undefined,
|
||||
yMin: str(o.yMin),
|
||||
yMax: str(o.yMax),
|
||||
xZoomMin: str(o.xZoomMin),
|
||||
xZoomMax: str(o.xZoomMax),
|
||||
xFieldOverride: typeof o.xFieldOverride === 'string' ? o.xFieldOverride : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function readPlotConfig(raw) {
|
||||
const c = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
||||
return {
|
||||
xField: typeof c.xField === 'string' ? c.xField : undefined,
|
||||
groupBy: typeof c.groupBy === 'string' ? c.groupBy : undefined,
|
||||
tickStep: Number.isFinite(c.tickStep) ? c.tickStep : undefined,
|
||||
metric: typeof c.metric === 'string' ? c.metric : undefined,
|
||||
hiddenSubjects: Array.isArray(c.hiddenSubjects) ? c.hiddenSubjects.filter((x) => typeof x === 'string') : [],
|
||||
sidebarOpen: c.sidebarOpen === true,
|
||||
counts: readChart(c.counts),
|
||||
rate: readChart(c.rate),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the tests to verify they pass**
|
||||
|
||||
Run: `cd frontend && npx jest tests/crossSubjectChart.test.js`
|
||||
Expected: PASS — all helper tests (including the new ones) green.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/lib/crossSubjectChart.js frontend/tests/crossSubjectChart.test.js
|
||||
git commit -m "feat(frontend): serialize/read helpers for plot config"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Frontend — hook refactor, API method, chart wiring, page props
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/api/client.js` (add `savePlotConfig` to `experimentsApi`, ~line 35)
|
||||
- Modify: `frontend/src/components/AnalysisCharts.jsx` (`useChartSettings` ~line 92; imports ~line 1; `ExperimentAnalysisCharts` ~line 448)
|
||||
- Modify: `frontend/src/pages/ExperimentDetail.jsx` (the `<ExperimentAnalysisCharts ... />` usage, ~line 365)
|
||||
- Test: `frontend/tests/ExperimentAnalysisCharts.test.jsx` (add hydration + save tests)
|
||||
|
||||
- [ ] **Step 1: Add the API client method**
|
||||
|
||||
In `frontend/src/api/client.js`, inside the `experimentsApi` object, add after the `updateSubjectTemplate` line (~line 35):
|
||||
|
||||
```js
|
||||
savePlotConfig: (id, config) => api.put(`/experiments/${id}/plot-config`, config).then((r) => r.data),
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Refactor `useChartSettings` to accept initial values and expose a snapshot**
|
||||
|
||||
In `frontend/src/components/AnalysisCharts.jsx`, replace the `useChartSettings` function (currently starting `function useChartSettings(defaultHeight) {` at ~line 92, through its `return {...}`/closing `}`) with this version. It adds an `initial` parameter (seeding the useState defaults) and a `snapshot` memo; everything else is unchanged:
|
||||
|
||||
```js
|
||||
function useChartSettings(defaultHeight, initial = {}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [height, setHeight] = useState(initial.height ?? defaultHeight);
|
||||
const [yMin, setYMin] = useState(initial.yMin ?? '');
|
||||
const [yMax, setYMax] = useState(initial.yMax ?? '');
|
||||
const [xZoomMin, setXZoomMin] = useState(initial.xZoomMin ?? '');
|
||||
const [xZoomMax, setXZoomMax] = useState(initial.xZoomMax ?? '');
|
||||
const [xFieldOverride, setXFieldOverride] = useState(initial.xFieldOverride ?? null); // null → use parent's xField
|
||||
|
||||
const yDomain = useMemo(() => [
|
||||
yMin !== '' ? parseFloat(yMin) : 'auto',
|
||||
yMax !== '' ? parseFloat(yMax) : 'auto',
|
||||
], [yMin, yMax]);
|
||||
|
||||
const xDomain = useMemo(() => {
|
||||
const hasMin = xZoomMin !== '';
|
||||
const hasMax = xZoomMax !== '';
|
||||
if (!hasMin && !hasMax) return null;
|
||||
return [hasMin ? parseFloat(xZoomMin) : 'dataMin', hasMax ? parseFloat(xZoomMax) : 'dataMax'];
|
||||
}, [xZoomMin, xZoomMax]);
|
||||
|
||||
const isDirty = yMin !== '' || yMax !== '' || xZoomMin !== '' || xZoomMax !== ''
|
||||
|| xFieldOverride !== null || height !== defaultHeight;
|
||||
|
||||
const snapshot = useMemo(
|
||||
() => ({ height, yMin, yMax, xZoomMin, xZoomMax, xFieldOverride }),
|
||||
[height, yMin, yMax, xZoomMin, xZoomMax, xFieldOverride],
|
||||
);
|
||||
|
||||
function reset() {
|
||||
setHeight(defaultHeight);
|
||||
setYMin(''); setYMax('');
|
||||
setXZoomMin(''); setXZoomMax('');
|
||||
setXFieldOverride(null);
|
||||
}
|
||||
|
||||
return {
|
||||
expanded, setExpanded,
|
||||
height, setHeight,
|
||||
yMin, setYMin, yMax, setYMax,
|
||||
xZoomMin, setXZoomMin, xZoomMax, setXZoomMax,
|
||||
xFieldOverride, setXFieldOverride,
|
||||
yDomain, xDomain, isDirty, reset, snapshot,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update imports in `AnalysisCharts.jsx`**
|
||||
|
||||
At the top of the file, change the React import to include `useRef`, and add the new helper imports to the existing `crossSubjectChart` import. The React import becomes:
|
||||
|
||||
```jsx
|
||||
import React, { useState, useMemo, useEffect, useRef } from 'react';
|
||||
```
|
||||
|
||||
And the `crossSubjectChart` import becomes:
|
||||
|
||||
```jsx
|
||||
import { sortPayloadByValueDesc, toggleInSet, setIdsHidden, serializePlotConfig, readPlotConfig } from '../lib/crossSubjectChart';
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the two new props and config-driven initialization to `ExperimentAnalysisCharts`**
|
||||
|
||||
Change the component signature (~line 448) to accept the new props:
|
||||
|
||||
```jsx
|
||||
export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectTemplate, dailyTemplate, plotConfig, onPersistConfig }) {
|
||||
```
|
||||
|
||||
Then, immediately after the `const defaultGroup = ...` line (~line 452) and BEFORE the existing `const [groupBy, ...]` state declarations, insert the once-only config read:
|
||||
|
||||
```jsx
|
||||
// Read the saved config exactly once (component only mounts after the experiment loads).
|
||||
const initialConfigRef = useRef(null);
|
||||
if (initialConfigRef.current === null) initialConfigRef.current = readPlotConfig(plotConfig);
|
||||
const initialConfig = initialConfigRef.current;
|
||||
```
|
||||
|
||||
Now replace the existing five state declarations (`groupBy`, `metric`, `xField`, `tickStep`, and the `sidebarOpen`/`hiddenSubjects` declarations) so each is seeded from `initialConfig`. Replace these lines:
|
||||
|
||||
```jsx
|
||||
const [groupBy, setGroupBy] = useState(defaultGroup);
|
||||
const [metric, setMetric] = useState('total');
|
||||
const [xField, setXField] = useState(() => defaultXField(xAxisFields));
|
||||
const [tickStep, setTickStep] = useState(1);
|
||||
|
||||
// Cross-subject interaction state (client-only; global across both charts)
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [hiddenSubjects, setHiddenSubjects] = useState(() => new Set());
|
||||
const [highlightedSubject, setHighlightedSubject] = useState(null);
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```jsx
|
||||
const [groupBy, setGroupBy] = useState(initialConfig.groupBy ?? defaultGroup);
|
||||
const [metric, setMetric] = useState(initialConfig.metric ?? 'total');
|
||||
const [xField, setXField] = useState(() => initialConfig.xField ?? defaultXField(xAxisFields));
|
||||
const [tickStep, setTickStep] = useState(initialConfig.tickStep ?? 1);
|
||||
|
||||
// Cross-subject interaction state (client-only; global across both charts)
|
||||
const [sidebarOpen, setSidebarOpen] = useState(initialConfig.sidebarOpen);
|
||||
const [hiddenSubjects, setHiddenSubjects] = useState(() => new Set(initialConfig.hiddenSubjects));
|
||||
const [highlightedSubject, setHighlightedSubject] = useState(null);
|
||||
```
|
||||
|
||||
And seed the two per-chart hooks from `initialConfig`. Replace:
|
||||
|
||||
```jsx
|
||||
const countsS = useChartSettings(200);
|
||||
const rateS = useChartSettings(160);
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```jsx
|
||||
const countsS = useChartSettings(200, initialConfig.counts);
|
||||
const rateS = useChartSettings(160, initialConfig.rate);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add the serialized config + debounced auto-save**
|
||||
|
||||
Immediately after the `const countsS`/`const rateS` lines (and the `toggleHidden`/`toggleGroup`/`showAll` handlers which are just above them), add the serialization memo, save-state, and debounced-save effect. Insert this block right after the `const rateS = ...` line:
|
||||
|
||||
```jsx
|
||||
// ── Persist display settings (debounced auto-save) ────────────────────────────
|
||||
const [saveState, setSaveState] = useState('idle'); // 'idle' | 'saving' | 'saved' | 'error'
|
||||
|
||||
const currentConfig = useMemo(
|
||||
() => serializePlotConfig({
|
||||
xField, groupBy, tickStep, metric, hiddenSubjects, sidebarOpen,
|
||||
counts: countsS.snapshot, rate: rateS.snapshot,
|
||||
}),
|
||||
[xField, groupBy, tickStep, metric, hiddenSubjects, sidebarOpen, countsS.snapshot, rateS.snapshot],
|
||||
);
|
||||
|
||||
// Keep the latest persist callback in a ref so the save effect does not re-run
|
||||
// when the parent passes a new inline function each render.
|
||||
const persistRef = useRef(onPersistConfig);
|
||||
persistRef.current = onPersistConfig;
|
||||
|
||||
const lastSavedRef = useRef(null);
|
||||
useEffect(() => {
|
||||
const serialized = JSON.stringify(currentConfig);
|
||||
if (lastSavedRef.current === null) { lastSavedRef.current = serialized; return; } // skip initial (hydration)
|
||||
if (serialized === lastSavedRef.current) return;
|
||||
if (!persistRef.current) { lastSavedRef.current = serialized; return; }
|
||||
setSaveState('saving');
|
||||
const t = setTimeout(() => {
|
||||
Promise.resolve(persistRef.current(currentConfig))
|
||||
.then(() => { lastSavedRef.current = serialized; setSaveState('saved'); })
|
||||
.catch(() => setSaveState('error'));
|
||||
}, 1000);
|
||||
return () => clearTimeout(t);
|
||||
}, [currentConfig]);
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Render the save-status hint in the global controls row**
|
||||
|
||||
Find the global-controls row in `ExperimentAnalysisCharts` — the `<div className="flex items-center flex-wrap gap-3">` containing the `<h2>Cross-subject metrics</h2>`, the X-axis select, Color-by select, `TickStepControl`, and the `▸ Subjects` button. The Subjects button currently has `ml-auto` in its className. Replace that standalone button block:
|
||||
|
||||
```jsx
|
||||
{hasData && (
|
||||
<button type="button" onClick={() => setSidebarOpen((o) => !o)}
|
||||
className={[
|
||||
'text-xs px-2 py-0.5 rounded border transition-colors ml-auto',
|
||||
sidebarOpen
|
||||
? 'bg-gray-100 border-gray-300 text-gray-700'
|
||||
: 'bg-white border-gray-200 text-gray-500 hover:text-gray-700 hover:border-gray-300',
|
||||
].join(' ')}>
|
||||
{sidebarOpen ? '◂ Subjects' : '▸ Subjects'}
|
||||
</button>
|
||||
)}
|
||||
```
|
||||
|
||||
with a wrapper that holds the status hint and the button (note `ml-auto` moved to the wrapper, removed from the button):
|
||||
|
||||
```jsx
|
||||
{hasData && (
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{saveState === 'saving' && <span className="text-xs text-gray-400">Saving…</span>}
|
||||
{saveState === 'saved' && <span className="text-xs text-emerald-500">Saved ✓</span>}
|
||||
{saveState === 'error' && <span className="text-xs text-amber-500">Couldn't save layout</span>}
|
||||
<button type="button" onClick={() => setSidebarOpen((o) => !o)}
|
||||
className={[
|
||||
'text-xs px-2 py-0.5 rounded border transition-colors',
|
||||
sidebarOpen
|
||||
? 'bg-gray-100 border-gray-300 text-gray-700'
|
||||
: 'bg-white border-gray-200 text-gray-500 hover:text-gray-700 hover:border-gray-300',
|
||||
].join(' ')}>
|
||||
{sidebarOpen ? '◂ Subjects' : '▸ Subjects'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Pass the new props from `ExperimentDetail.jsx`**
|
||||
|
||||
In `frontend/src/pages/ExperimentDetail.jsx`, find the `<ExperimentAnalysisCharts ... />` usage (~line 365) and add the two props. It must use the page's existing `experiment` object and `id` (from `useParams`) and the already-imported `experimentsApi`:
|
||||
|
||||
```jsx
|
||||
<ExperimentAnalysisCharts
|
||||
animals={animals}
|
||||
experimentStatuses={experimentStatuses}
|
||||
subjectTemplate={subjectTemplate}
|
||||
dailyTemplate={dailyTemplate}
|
||||
plotConfig={experiment.plot_config}
|
||||
onPersistConfig={(cfg) => experimentsApi.savePlotConfig(id, cfg)}
|
||||
/>
|
||||
```
|
||||
|
||||
(If `id` is not already in scope, use `experiment.id` instead. Verify which identifier the file uses before editing.)
|
||||
|
||||
- [ ] **Step 8: Write the hydration + save tests**
|
||||
|
||||
Append these two tests to `frontend/tests/ExperimentAnalysisCharts.test.jsx`. Note the file already mocks `recharts` and defines `ANIMALS`, `STATUSES`, `SUBJECT_TEMPLATE`, `DAILY_TEMPLATE`, and `renderChart()`. Add `act` to the testing-library import at the top of the file (change `import { render, screen, fireEvent } from '@testing-library/react';` to include `act`):
|
||||
|
||||
```jsx
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
```
|
||||
|
||||
Then append:
|
||||
|
||||
```jsx
|
||||
it('hydrates hidden subjects and sidebar-open from plotConfig', () => {
|
||||
render(
|
||||
<ExperimentAnalysisCharts
|
||||
animals={ANIMALS}
|
||||
experimentStatuses={STATUSES}
|
||||
subjectTemplate={SUBJECT_TEMPLATE}
|
||||
dailyTemplate={DAILY_TEMPLATE}
|
||||
plotConfig={{ hiddenSubjects: ['a1'], sidebarOpen: true }}
|
||||
/>,
|
||||
);
|
||||
// sidebar starts open (no click needed) and a1 starts hidden (unchecked)
|
||||
expect(screen.getByLabelText('Mouse-A1').checked).toBe(false);
|
||||
expect(screen.getByLabelText('Mouse-B1').checked).toBe(true);
|
||||
});
|
||||
|
||||
describe('debounced auto-save', () => {
|
||||
beforeEach(() => jest.useFakeTimers());
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
it('does not call onPersistConfig on initial render', () => {
|
||||
const onPersist = jest.fn();
|
||||
render(
|
||||
<ExperimentAnalysisCharts
|
||||
animals={ANIMALS} experimentStatuses={STATUSES}
|
||||
subjectTemplate={SUBJECT_TEMPLATE} dailyTemplate={DAILY_TEMPLATE}
|
||||
plotConfig={null} onPersistConfig={onPersist}
|
||||
/>,
|
||||
);
|
||||
act(() => { jest.advanceTimersByTime(1500); });
|
||||
expect(onPersist).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onPersistConfig ~1s after a change, with the updated config', () => {
|
||||
const onPersist = jest.fn().mockResolvedValue({});
|
||||
render(
|
||||
<ExperimentAnalysisCharts
|
||||
animals={ANIMALS} experimentStatuses={STATUSES}
|
||||
subjectTemplate={SUBJECT_TEMPLATE} dailyTemplate={DAILY_TEMPLATE}
|
||||
plotConfig={{ sidebarOpen: true }} onPersistConfig={onPersist}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText('Mouse-A1')); // hide a1 → config change
|
||||
expect(onPersist).not.toHaveBeenCalled(); // not yet (debounced)
|
||||
act(() => { jest.advanceTimersByTime(1100); });
|
||||
expect(onPersist).toHaveBeenCalledTimes(1);
|
||||
expect(onPersist.mock.calls[0][0].hiddenSubjects).toContain('a1');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Run the chart test file to verify it passes**
|
||||
|
||||
Run: `cd frontend && npx jest tests/ExperimentAnalysisCharts.test.jsx`
|
||||
Expected: PASS — the two original smoke tests plus the three new tests green. If a test reveals a genuine wiring problem, fix the wiring (do not weaken the test).
|
||||
|
||||
- [ ] **Step 10: Run the full frontend suite (no new regressions)**
|
||||
|
||||
Run: `cd frontend && npm test`
|
||||
Expected: PASS for all suites except the pre-existing `ExperimentCalendar` / `ExperimentDayView` failures (31 failures that also fail on `main` — unrelated). All other suites, including `crossSubjectChart`, `SubjectSidebar`, and `ExperimentAnalysisCharts`, green.
|
||||
|
||||
- [ ] **Step 11: Build to confirm no compile errors**
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
Expected: Vite build succeeds with no errors.
|
||||
|
||||
- [ ] **Step 12: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/api/client.js frontend/src/components/AnalysisCharts.jsx frontend/src/pages/ExperimentDetail.jsx frontend/tests/ExperimentAnalysisCharts.test.jsx
|
||||
git commit -m "feat(frontend): hydrate + debounced-persist cross-subject plot config"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Manual Verification (after all tasks, requires DB migration applied)
|
||||
|
||||
The running stack applies migrations on backend startup (`prisma migrate deploy` in the entrypoint). After `docker compose up --build`:
|
||||
|
||||
- [ ] Open an experiment with animals + saved metrics. Change X-axis, toggle a few subjects off, open the sidebar, adjust a per-chart size. Observe a brief "Saving… → Saved ✓".
|
||||
- [ ] Reload the page → the same X-axis, hidden subjects, sidebar state, and chart size are restored.
|
||||
- [ ] Open a different experiment → it has its own independent settings (or defaults if never configured).
|
||||
- [ ] Confirm no new `audit_logs` rows are created by plot tweaks (only by template edits etc.).
|
||||
- [ ] Existing controls and the previously-shipped interactions (legend toggle/highlight, ordered tooltip) still work.
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
- **Spec coverage:** migration + nullable column (Task 1 Steps 3–4); `PUT /:id/plot-config` with validator, no audit log (Task 1 Steps 6–7, asserted in Step 1); read via existing `GET /:id` (no code needed — Prisma returns the column); API client method (Task 3 Step 1); `serializePlotConfig`/`readPlotConfig` with defensive defaults (Task 2); `useChartSettings` `initial`+`snapshot` refactor (Task 3 Step 2); hydrate-once + serialize + debounced save + status hint (Task 3 Steps 4–6); `ExperimentDetail` props (Task 3 Step 7); tests at all three layers (Task 1 Step 1, Task 2 Step 1, Task 3 Step 8); manual checks. All spec sections covered.
|
||||
- **No-audit-log requirement:** explicitly asserted by `expect(prisma.auditLog.create).not.toHaveBeenCalled()` (Task 1 Step 1).
|
||||
- **Save-on-load loop prevention:** `lastSavedRef` initial-null skip (Task 3 Step 5) + the "does not call onPersistConfig on initial render" test (Task 3 Step 8).
|
||||
- **Naming consistency:** `plot_config` (DB/route), `plotConfig`/`onPersistConfig` (props), `serializePlotConfig`/`readPlotConfig` (helpers), `snapshot` (hook) used identically across tasks.
|
||||
- **No disallowed changes:** `SubjectAnalysisCharts` calls `useChartSettings(defaultHeight)` with no `initial`, which defaults to `{}` → unchanged behavior; template/subject-template routes untouched.
|
||||
@@ -0,0 +1,705 @@
|
||||
# Experiment Data Export (CSV) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a client-side CSV export on the experiment page that produces a date×subject matrix for any chosen daily parameter (including session metrics).
|
||||
|
||||
**Architecture:** A pure, unit-tested helper module (`frontend/src/lib/dataExport.js`) enumerates exportable parameters, pivots fetched daily statuses into a date-row × subject-column matrix, and serializes to CSV. A modal component (`ExportDataModal.jsx`) fetches the full daily-statuses set on open, drives the picker, and triggers a browser download. `ExperimentDetail.jsx` gets an "Export data" button. No backend changes — the existing `GET /:id/daily-statuses` endpoint already returns every field needed.
|
||||
|
||||
**Tech Stack:** React 18, Jest + @testing-library, existing `experimentsApi` axios client, existing `Modal`/`Button` UI components.
|
||||
|
||||
---
|
||||
|
||||
## Reference: data shapes (read before starting)
|
||||
|
||||
**Daily template field** (`dailyTemplate` array items):
|
||||
```js
|
||||
{ fieldId: '0000…0002', key: 'vitals', label: 'Vitals', type: 'text', builtin: true, active: true, tags: [] }
|
||||
```
|
||||
- `builtin: true` → value stored at `status[field.key]` (e.g. `status.vitals`).
|
||||
- `builtin: false` → value stored at `status.custom_fields[field.fieldId]`.
|
||||
|
||||
**Daily status** (from `experimentsApi.getDailyStatuses(id, {})`):
|
||||
```js
|
||||
{
|
||||
id, animal_id, date: '2026-07-01T00:00:00.000Z',
|
||||
experiment_description, vitals, treatment, notes, // builtin columns
|
||||
custom_fields: { '<fieldId>': value, … } | null,
|
||||
analysis_summary: { total: 12, success_rate: 0.83, counts: { Success: 10, Failure: 2 } } | null,
|
||||
}
|
||||
```
|
||||
|
||||
**Animal**: `{ id, animal_name, animal_id_string, subject_info, … }`.
|
||||
|
||||
**Parameter descriptor** (produced by `listExportableParams`, consumed everywhere):
|
||||
```js
|
||||
{ id, label, group: 'metrics' | 'daily', kind: 'total'|'success_rate'|'category'|'builtin'|'custom',
|
||||
category?, statusKey?, fieldId? }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `extractValue` — read one parameter's value from a status
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/lib/dataExport.js`
|
||||
- Test: `frontend/tests/dataExport.test.js`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `frontend/tests/dataExport.test.js`:
|
||||
```js
|
||||
import { extractValue } from '../src/lib/dataExport';
|
||||
|
||||
const status = {
|
||||
vitals: 'ok',
|
||||
custom_fields: { 'f-weight': 250 },
|
||||
analysis_summary: { total: 12, success_rate: 0.83, counts: { Success: 10, Failure: 2 } },
|
||||
};
|
||||
|
||||
describe('extractValue', () => {
|
||||
it('reads a builtin field via statusKey', () => {
|
||||
expect(extractValue(status, { kind: 'builtin', statusKey: 'vitals' })).toBe('ok');
|
||||
});
|
||||
it('reads a custom field via fieldId', () => {
|
||||
expect(extractValue(status, { kind: 'custom', fieldId: 'f-weight' })).toBe(250);
|
||||
});
|
||||
it('reads session-metric total', () => {
|
||||
expect(extractValue(status, { kind: 'total' })).toBe(12);
|
||||
});
|
||||
it('reads session-metric success_rate as a raw 0-1 fraction', () => {
|
||||
expect(extractValue(status, { kind: 'success_rate' })).toBe(0.83);
|
||||
});
|
||||
it('reads a dynamic count category', () => {
|
||||
expect(extractValue(status, { kind: 'category', category: 'Failure' })).toBe(2);
|
||||
});
|
||||
it('returns null when analysis_summary is absent', () => {
|
||||
expect(extractValue({ custom_fields: {} }, { kind: 'total' })).toBeNull();
|
||||
expect(extractValue({}, { kind: 'category', category: 'Success' })).toBeNull();
|
||||
});
|
||||
it('returns null for a missing custom field', () => {
|
||||
expect(extractValue({ custom_fields: {} }, { kind: 'custom', fieldId: 'nope' })).toBeNull();
|
||||
expect(extractValue({}, { kind: 'builtin', statusKey: 'notes' })).toBeNull();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd frontend && npx jest tests/dataExport.test.js -t extractValue`
|
||||
Expected: FAIL — "extractValue is not a function" / cannot import.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Create `frontend/src/lib/dataExport.js`:
|
||||
```js
|
||||
// Pure helpers for exporting daily-parameter data as a date×subject CSV matrix.
|
||||
// No React, no I/O — mirrors the crossSubjectChart.js pure-helper pattern.
|
||||
|
||||
// Read a single parameter's raw value from one daily status.
|
||||
// Returns null when the value is absent. Note: 0 and '' are returned as-is
|
||||
// (real values); only genuinely-missing lookups become null.
|
||||
export function extractValue(status, param) {
|
||||
if (!status || !param) return null;
|
||||
switch (param.kind) {
|
||||
case 'total':
|
||||
return status.analysis_summary?.total ?? null;
|
||||
case 'success_rate':
|
||||
return status.analysis_summary?.success_rate ?? null;
|
||||
case 'category':
|
||||
return status.analysis_summary?.counts?.[param.category] ?? null;
|
||||
case 'builtin':
|
||||
return status[param.statusKey] ?? null;
|
||||
case 'custom':
|
||||
return status.custom_fields?.[param.fieldId] ?? null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `cd frontend && npx jest tests/dataExport.test.js -t extractValue`
|
||||
Expected: PASS (7 assertions).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/lib/dataExport.js frontend/tests/dataExport.test.js
|
||||
git commit -m "feat(frontend): extractValue helper for data export"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `listExportableParams` — enumerate the picker options
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/lib/dataExport.js`
|
||||
- Test: `frontend/tests/dataExport.test.js`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Append to `frontend/tests/dataExport.test.js`:
|
||||
```js
|
||||
import { listExportableParams } from '../src/lib/dataExport';
|
||||
|
||||
const dailyTemplate = [
|
||||
{ fieldId: 'b-vitals', key: 'vitals', label: 'Vitals', builtin: true, active: true },
|
||||
{ fieldId: 'c-weight', key: 'weight', label: 'Weight (g)', builtin: false, active: false },
|
||||
];
|
||||
const statuses = [
|
||||
{ analysis_summary: { counts: { Success: 1, Failure: 0 } } },
|
||||
{ analysis_summary: { counts: { Success: 2, Other: 1 } } },
|
||||
{ analysis_summary: null },
|
||||
];
|
||||
|
||||
describe('listExportableParams', () => {
|
||||
const params = listExportableParams(dailyTemplate, statuses);
|
||||
it('starts with the two fixed session metrics', () => {
|
||||
expect(params.slice(0, 2)).toEqual([
|
||||
expect.objectContaining({ id: '__total__', label: 'Total attempts', group: 'metrics', kind: 'total' }),
|
||||
expect.objectContaining({ id: '__success_rate__', label: 'Success rate', group: 'metrics', kind: 'success_rate' }),
|
||||
]);
|
||||
});
|
||||
it('adds one metrics entry per dynamic count category, sorted', () => {
|
||||
const cats = params.filter((p) => p.kind === 'category').map((p) => p.label);
|
||||
expect(cats).toEqual(['Failure', 'Other', 'Success']);
|
||||
});
|
||||
it('includes every template field (incl. inactive) in the daily group', () => {
|
||||
const daily = params.filter((p) => p.group === 'daily');
|
||||
expect(daily).toEqual([
|
||||
expect.objectContaining({ label: 'Vitals', kind: 'builtin', statusKey: 'vitals' }),
|
||||
expect.objectContaining({ label: 'Weight (g)', kind: 'custom', fieldId: 'c-weight' }),
|
||||
]);
|
||||
});
|
||||
it('gives every param a unique id', () => {
|
||||
const ids = params.map((p) => p.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
it('tolerates empty/missing inputs', () => {
|
||||
expect(listExportableParams(undefined, undefined).length).toBe(2); // just the two fixed metrics
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd frontend && npx jest tests/dataExport.test.js -t listExportableParams`
|
||||
Expected: FAIL — "listExportableParams is not a function".
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Append to `frontend/src/lib/dataExport.js`:
|
||||
```js
|
||||
// Build the grouped, ordered list of exportable parameters.
|
||||
// Session metrics (total, success_rate, then each dynamic count category found
|
||||
// in the data) come first; then every field defined in the daily template,
|
||||
// including inactive ones (historical data may exist).
|
||||
export function listExportableParams(dailyTemplate, statuses) {
|
||||
const params = [
|
||||
{ id: '__total__', label: 'Total attempts', group: 'metrics', kind: 'total' },
|
||||
{ id: '__success_rate__', label: 'Success rate', group: 'metrics', kind: 'success_rate' },
|
||||
];
|
||||
|
||||
const cats = new Set();
|
||||
for (const s of statuses ?? []) {
|
||||
const counts = s?.analysis_summary?.counts;
|
||||
if (counts && typeof counts === 'object') {
|
||||
for (const k of Object.keys(counts)) cats.add(k);
|
||||
}
|
||||
}
|
||||
for (const cat of [...cats].sort()) {
|
||||
params.push({ id: `__cat__${cat}`, label: cat, group: 'metrics', kind: 'category', category: cat });
|
||||
}
|
||||
|
||||
for (const f of dailyTemplate ?? []) {
|
||||
if (f.builtin) {
|
||||
params.push({ id: `b:${f.fieldId}`, label: f.label, group: 'daily', kind: 'builtin', statusKey: f.key });
|
||||
} else {
|
||||
params.push({ id: `c:${f.fieldId}`, label: f.label, group: 'daily', kind: 'custom', fieldId: f.fieldId });
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `cd frontend && npx jest tests/dataExport.test.js -t listExportableParams`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/lib/dataExport.js frontend/tests/dataExport.test.js
|
||||
git commit -m "feat(frontend): listExportableParams enumerates export options"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `buildMatrix` — pivot statuses into date rows × subject columns
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/lib/dataExport.js`
|
||||
- Test: `frontend/tests/dataExport.test.js`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Append to `frontend/tests/dataExport.test.js`:
|
||||
```js
|
||||
import { buildMatrix } from '../src/lib/dataExport';
|
||||
|
||||
const animals = [
|
||||
{ id: 'a2', animal_name: 'Beta', animal_id_string: 'R-002' },
|
||||
{ id: 'a1', animal_name: 'Alpha', animal_id_string: 'R-001' },
|
||||
];
|
||||
const totalParam = { kind: 'total' };
|
||||
|
||||
describe('buildMatrix', () => {
|
||||
it('orders columns by animal_name and rows by date ascending', () => {
|
||||
const statuses = [
|
||||
{ animal_id: 'a1', date: '2026-07-02T00:00:00.000Z', analysis_summary: { total: 5 } },
|
||||
{ animal_id: 'a2', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 9 } },
|
||||
];
|
||||
const m = buildMatrix(statuses, animals, totalParam);
|
||||
expect(m.columns.map((c) => c.header)).toEqual(['Alpha', 'Beta']);
|
||||
expect(m.rows.map((r) => r.date)).toEqual(['2026-07-01', '2026-07-02']);
|
||||
// Row for 07-01: Alpha blank, Beta 9
|
||||
expect(m.rows[0].values).toEqual(['', 9]);
|
||||
// Row for 07-02: Alpha 5, Beta blank
|
||||
expect(m.rows[1].values).toEqual([5, '']);
|
||||
});
|
||||
it('includes only dates that have at least one value (0 counts as a value)', () => {
|
||||
const statuses = [
|
||||
{ animal_id: 'a1', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 0 } },
|
||||
{ animal_id: 'a1', date: '2026-07-03T00:00:00.000Z', analysis_summary: null }, // no value
|
||||
];
|
||||
const m = buildMatrix(statuses, animals, totalParam);
|
||||
expect(m.rows.map((r) => r.date)).toEqual(['2026-07-01']);
|
||||
expect(m.rows[0].values).toEqual([0, '']);
|
||||
});
|
||||
it('disambiguates duplicate animal names with the id string', () => {
|
||||
const dupAnimals = [
|
||||
{ id: 'a1', animal_name: 'Rat', animal_id_string: 'R-001' },
|
||||
{ id: 'a2', animal_name: 'Rat', animal_id_string: 'R-002' },
|
||||
];
|
||||
const statuses = [{ animal_id: 'a1', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 1 } }];
|
||||
const m = buildMatrix(statuses, dupAnimals, totalParam);
|
||||
expect(m.columns.map((c) => c.header)).toEqual(['Rat (R-001)', 'Rat (R-002)']);
|
||||
});
|
||||
it('keeps the first status when a subject has duplicates on one date', () => {
|
||||
const statuses = [
|
||||
{ animal_id: 'a1', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 7 } },
|
||||
{ animal_id: 'a1', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 99 } },
|
||||
];
|
||||
const m = buildMatrix(statuses, animals, totalParam);
|
||||
expect(m.rows[0].values).toEqual([7, '']);
|
||||
});
|
||||
it('returns empty rows when no status has a value', () => {
|
||||
const m = buildMatrix([{ animal_id: 'a1', date: '2026-07-01T00:00:00.000Z', analysis_summary: null }], animals, totalParam);
|
||||
expect(m.rows).toEqual([]);
|
||||
expect(m.columns.length).toBe(2);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd frontend && npx jest tests/dataExport.test.js -t buildMatrix`
|
||||
Expected: FAIL — "buildMatrix is not a function".
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Append to `frontend/src/lib/dataExport.js`:
|
||||
```js
|
||||
// A cell has a value unless it is null, undefined, or the empty string.
|
||||
// (0 and false are real values.)
|
||||
function hasValue(v) {
|
||||
return v !== null && v !== undefined && v !== '';
|
||||
}
|
||||
|
||||
function dateKey(date) {
|
||||
return String(date).slice(0, 10); // ISO datetime or date → YYYY-MM-DD
|
||||
}
|
||||
|
||||
// Pivot statuses into { columns, rows }.
|
||||
// columns: [{ id, header }] — all animals, ordered by name, headers
|
||||
// disambiguated with animal_id_string on duplicate names.
|
||||
// rows: [{ date, values }] — one per date that has ≥1 value; values are
|
||||
// aligned to columns, blank cells are '' (empty string).
|
||||
export function buildMatrix(statuses, animals, param) {
|
||||
const sortedAnimals = [...(animals ?? [])].sort((a, b) =>
|
||||
String(a.animal_name ?? '').localeCompare(String(b.animal_name ?? '')),
|
||||
);
|
||||
|
||||
const nameCounts = new Map();
|
||||
for (const a of sortedAnimals) {
|
||||
const n = a.animal_name ?? '';
|
||||
nameCounts.set(n, (nameCounts.get(n) ?? 0) + 1);
|
||||
}
|
||||
const columns = sortedAnimals.map((a) => {
|
||||
const name = a.animal_name ?? '';
|
||||
const header = nameCounts.get(name) > 1 ? `${name} (${a.animal_id_string ?? ''})` : name;
|
||||
return { id: a.id, header };
|
||||
});
|
||||
const colIndex = new Map(columns.map((c, i) => [c.id, i]));
|
||||
|
||||
// cells: dateKey -> Map(animalId -> value); first status per (date, animal) wins.
|
||||
const cells = new Map();
|
||||
const datesWithData = new Set();
|
||||
const ordered = [...(statuses ?? [])].sort((a, b) => dateKey(a.date).localeCompare(dateKey(b.date)));
|
||||
for (const s of ordered) {
|
||||
if (!colIndex.has(s.animal_id)) continue;
|
||||
const dk = dateKey(s.date);
|
||||
if (!cells.has(dk)) cells.set(dk, new Map());
|
||||
const byAnimal = cells.get(dk);
|
||||
if (byAnimal.has(s.animal_id)) continue; // first wins
|
||||
const v = extractValue(s, param);
|
||||
byAnimal.set(s.animal_id, v);
|
||||
if (hasValue(v)) datesWithData.add(dk);
|
||||
}
|
||||
|
||||
const rows = [...datesWithData].sort().map((dk) => {
|
||||
const byAnimal = cells.get(dk);
|
||||
const values = columns.map((c) => {
|
||||
const v = byAnimal.has(c.id) ? byAnimal.get(c.id) : null;
|
||||
return hasValue(v) ? v : '';
|
||||
});
|
||||
return { date: dk, values };
|
||||
});
|
||||
|
||||
return { columns, rows };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `cd frontend && npx jest tests/dataExport.test.js -t buildMatrix`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/lib/dataExport.js frontend/tests/dataExport.test.js
|
||||
git commit -m "feat(frontend): buildMatrix pivots statuses into date×subject grid"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `toCSV` + `csvFilename` — serialize and name the file
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/lib/dataExport.js`
|
||||
- Test: `frontend/tests/dataExport.test.js`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Append to `frontend/tests/dataExport.test.js`:
|
||||
```js
|
||||
import { toCSV, csvFilename } from '../src/lib/dataExport';
|
||||
|
||||
describe('toCSV', () => {
|
||||
it('writes a header row of Date + column headers, then data rows', () => {
|
||||
const matrix = {
|
||||
columns: [{ id: 'a1', header: 'Alpha' }, { id: 'a2', header: 'Beta' }],
|
||||
rows: [{ date: '2026-07-01', values: [5, ''] }, { date: '2026-07-02', values: ['', 9] }],
|
||||
};
|
||||
expect(toCSV(matrix)).toBe('Date,Alpha,Beta\n2026-07-01,5,\n2026-07-02,,9');
|
||||
});
|
||||
it('escapes commas, quotes, and newlines per RFC 4180', () => {
|
||||
const matrix = {
|
||||
columns: [{ id: 'a1', header: 'Note, field' }],
|
||||
rows: [{ date: '2026-07-01', values: ['he said "hi"'] }, { date: '2026-07-02', values: ['line1\nline2'] }],
|
||||
};
|
||||
expect(toCSV(matrix)).toBe(
|
||||
'Date,"Note, field"\n2026-07-01,"he said ""hi"""\n2026-07-02,"line1\nline2"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('csvFilename', () => {
|
||||
it('slugifies the experiment title and parameter label', () => {
|
||||
expect(csvFilename('My Study #1', 'Success rate')).toBe('My-Study-1-Success-rate.csv');
|
||||
});
|
||||
it('falls back to "export" when both slugs are empty', () => {
|
||||
expect(csvFilename('', '')).toBe('export.csv');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd frontend && npx jest tests/dataExport.test.js -t "toCSV|csvFilename"`
|
||||
Expected: FAIL — "toCSV is not a function".
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Append to `frontend/src/lib/dataExport.js`:
|
||||
```js
|
||||
function escapeCsvCell(value) {
|
||||
const s = value === null || value === undefined ? '' : String(value);
|
||||
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
||||
}
|
||||
|
||||
// Serialize a matrix to an RFC-4180-ish CSV string (LF line endings).
|
||||
// First column is the date; remaining columns follow matrix.columns order.
|
||||
export function toCSV(matrix) {
|
||||
const header = ['Date', ...matrix.columns.map((c) => c.header)].map(escapeCsvCell).join(',');
|
||||
const lines = matrix.rows.map((r) => [r.date, ...r.values].map(escapeCsvCell).join(','));
|
||||
return [header, ...lines].join('\n');
|
||||
}
|
||||
|
||||
// Build a download filename from the experiment title and parameter label.
|
||||
export function csvFilename(title, label) {
|
||||
const slug = (s) => String(s ?? '').replace(/[^a-z0-9]+/gi, '-').replace(/^-+|-+$/g, '');
|
||||
const base = [slug(title), slug(label)].filter(Boolean).join('-');
|
||||
return `${base || 'export'}.csv`;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `cd frontend && npx jest tests/dataExport.test.js`
|
||||
Expected: PASS (all describe blocks in the file).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/lib/dataExport.js frontend/tests/dataExport.test.js
|
||||
git commit -m "feat(frontend): toCSV + csvFilename for data export"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: `ExportDataModal` component
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/ExportDataModal.jsx`
|
||||
|
||||
This component is rendered as the *content* of the shared `Modal` (same pattern as `AnimalForm`/`TemplateEditor`), so it mounts only when the modal opens — the fetch runs on mount.
|
||||
|
||||
- [ ] **Step 1: Create the component**
|
||||
|
||||
Create `frontend/src/components/ExportDataModal.jsx`:
|
||||
```jsx
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { experimentsApi } from '../api/client';
|
||||
import Button from './ui/Button';
|
||||
import Alert from './ui/Alert';
|
||||
import { listExportableParams, buildMatrix, toCSV, csvFilename } from '../lib/dataExport';
|
||||
|
||||
const GROUP_LABELS = { metrics: 'Session metrics', daily: 'Daily record fields' };
|
||||
|
||||
function triggerDownload(filename, text) {
|
||||
const blob = new Blob([text], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export default function ExportDataModal({ experimentTitle, experimentId, dailyTemplate, animals, onClose }) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [statuses, setStatuses] = useState([]);
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
experimentsApi.getDailyStatuses(experimentId, {})
|
||||
.then((data) => { if (alive) setStatuses(data); })
|
||||
.catch((err) => { if (alive) setError(err.message); })
|
||||
.finally(() => { if (alive) setLoading(false); });
|
||||
return () => { alive = false; };
|
||||
}, [experimentId]);
|
||||
|
||||
const params = useMemo(() => listExportableParams(dailyTemplate, statuses), [dailyTemplate, statuses]);
|
||||
|
||||
// Default the selection to the first parameter once params are available.
|
||||
useEffect(() => {
|
||||
if (selectedId === null && params.length > 0) setSelectedId(params[0].id);
|
||||
}, [params, selectedId]);
|
||||
|
||||
const selectedParam = params.find((p) => p.id === selectedId) ?? null;
|
||||
|
||||
const matrix = useMemo(
|
||||
() => (selectedParam ? buildMatrix(statuses, animals, selectedParam) : { columns: [], rows: [] }),
|
||||
[statuses, animals, selectedParam],
|
||||
);
|
||||
|
||||
const hasData = matrix.rows.length > 0;
|
||||
|
||||
// Group params for <optgroup> rendering, preserving order.
|
||||
const grouped = useMemo(() => {
|
||||
const out = [];
|
||||
for (const p of params) {
|
||||
let g = out.find((x) => x.group === p.group);
|
||||
if (!g) { g = { group: p.group, items: [] }; out.push(g); }
|
||||
g.items.push(p);
|
||||
}
|
||||
return out;
|
||||
}, [params]);
|
||||
|
||||
function handleExport() {
|
||||
if (!hasData || !selectedParam) return;
|
||||
triggerDownload(csvFilename(experimentTitle, selectedParam.label), toCSV(matrix));
|
||||
onClose();
|
||||
}
|
||||
|
||||
if (loading) return <p className="text-sm text-gray-400" aria-live="polite">Loading data…</p>;
|
||||
if (error) return <Alert type="error" message={error} />;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
Export one parameter as a CSV file — each row is a date, each column is a subject.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label htmlFor="export-param" className="block text-xs font-medium text-gray-500 mb-1">Parameter</label>
|
||||
<select
|
||||
id="export-param"
|
||||
value={selectedId ?? ''}
|
||||
onChange={(e) => setSelectedId(e.target.value)}
|
||||
className="w-full border border-gray-200 rounded px-2 py-1.5 text-sm bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400"
|
||||
>
|
||||
{grouped.map((g) => (
|
||||
<optgroup key={g.group} label={GROUP_LABELS[g.group] ?? g.group}>
|
||||
{g.items.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400">
|
||||
{hasData
|
||||
? `${matrix.rows.length} date${matrix.rows.length !== 1 ? 's' : ''} × ${matrix.columns.length} subject${matrix.columns.length !== 1 ? 's' : ''}`
|
||||
: 'No data for this parameter yet.'}
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={handleExport} disabled={!hasData}>Export CSV</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify it compiles (build)**
|
||||
|
||||
Run: `cd frontend && npx vite build`
|
||||
Expected: build succeeds with no import/JSX errors. (There is no `ExportDataModal` test — its logic lives in the unit-tested `dataExport.js`; the DOM download path is verified manually in Task 7.)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/ExportDataModal.jsx
|
||||
git commit -m "feat(frontend): ExportDataModal — pick a parameter and download CSV"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Wire the "Export data" button into the experiment page
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/ExperimentDetail.jsx`
|
||||
|
||||
- [ ] **Step 1: Import the modal**
|
||||
|
||||
In `frontend/src/pages/ExperimentDetail.jsx`, add to the imports near the other component imports (after the `ExperimentCalendar` import on line 11):
|
||||
```jsx
|
||||
import ExportDataModal from '../components/ExportDataModal';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add modal open/close state**
|
||||
|
||||
Immediately after the `const [showSubjectTemplate, setShowSubjectTemplate] = useState(false);` line (around line 64), add:
|
||||
```jsx
|
||||
const [showExport, setShowExport] = useState(false);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the header button**
|
||||
|
||||
In the header actions area, replace the single Add-Animal button block:
|
||||
```jsx
|
||||
<Button onClick={() => setShowAddAnimal(true)}>+ Add Animal</Button>
|
||||
```
|
||||
with:
|
||||
```jsx
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={() => setShowExport(true)}>Export data</Button>
|
||||
<Button onClick={() => setShowAddAnimal(true)}>+ Add Animal</Button>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Render the modal**
|
||||
|
||||
Just before the closing `</div>` and the final Edit-Animal `Modal` (after the `Edit Animal` modal block, around line 406), add:
|
||||
```jsx
|
||||
<Modal isOpen={showExport} onClose={() => setShowExport(false)} title="Export data" size="md">
|
||||
<ExportDataModal
|
||||
experimentId={id}
|
||||
experimentTitle={experiment.title}
|
||||
dailyTemplate={dailyTemplate}
|
||||
animals={animals}
|
||||
onClose={() => setShowExport(false)}
|
||||
/>
|
||||
</Modal>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Build to verify wiring**
|
||||
|
||||
Run: `cd frontend && npx vite build`
|
||||
Expected: build succeeds.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/pages/ExperimentDetail.jsx
|
||||
git commit -m "feat(frontend): Export data button + modal on experiment page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Full-suite verification & manual smoke test
|
||||
|
||||
**Files:** none (verification only)
|
||||
|
||||
- [ ] **Step 1: Run the whole frontend unit suite**
|
||||
|
||||
Run: `cd frontend && npm test`
|
||||
Expected: all suites PASS, including `tests/dataExport.test.js`.
|
||||
|
||||
- [ ] **Step 2: Manual smoke test**
|
||||
|
||||
Bring up the dev stack (`docker-compose -f docker-compose.dev.yml up` or the project's usual dev command), open an experiment that has animals and some daily statuses, then:
|
||||
- Click **Export data** → modal opens, parameter dropdown shows a **Session metrics** group (Total attempts, Success rate, any count categories) and a **Daily record fields** group (all template fields).
|
||||
- The summary line shows a plausible "N dates × M subjects".
|
||||
- Pick a session metric and a daily field; confirm the summary updates and **Export CSV** downloads a file.
|
||||
- Open the CSV: first column `Date` (YYYY-MM-DD rows ascending), one column per subject (by name), correct values, blank cells where a subject had no value, and **success rate as a raw 0–1 fraction**.
|
||||
- Pick a parameter with no data → summary reads "No data for this parameter yet." and **Export CSV** is disabled.
|
||||
|
||||
- [ ] **Step 3: Final commit (if any manual fixes were needed)**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore(frontend): finalize experiment data export"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes (for the implementer)
|
||||
|
||||
- The whole feature is client-side; **no backend files change**. If a test asks you to touch `backend/`, stop — the plan is wrong.
|
||||
- `success_rate` is exported **raw (0–1)**, deliberately not multiplied to a percentage.
|
||||
- All daily template fields are exportable, **including inactive ones** — this is intentional (historical data).
|
||||
- Parameter descriptor property names (`kind`, `statusKey`, `fieldId`, `category`, `group`, `id`, `label`) must stay identical across `listExportableParams`, `extractValue`, `buildMatrix`, and `ExportDataModal`.
|
||||
@@ -0,0 +1,926 @@
|
||||
# Configurable Data Export Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Let the export modal assign any of Date/Subject/Parameter to rows and columns, pin the third dimension (shown as a top context line), and emit each subject's group in a separate cell when Subject is an axis.
|
||||
|
||||
**Architecture:** Generalize the pure helpers in `frontend/src/lib/dataExport.js` around three interchangeable dimensions (date, subject, parameter). Each dimension produces an ordered list of "members"; `buildMatrix` resolves every cell's `(dateKey, animalId, param)` triple through a status index. `ExportDataModal.jsx` becomes glue over these helpers with four controls. `ExperimentDetail.jsx` passes two new props.
|
||||
|
||||
**Tech Stack:** React 18, plain ES modules, Jest + `@testing-library/react` (jsdom). No new dependencies.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-19-configurable-data-export-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- `dataExport.js` stays pure: no React, no I/O (mirrors `crossSubjectChart.js`).
|
||||
- Cell value semantics: `0` and `''` are **real** values; only genuinely-missing lookups render as blank (`''`). Use the existing `hasValue` helper.
|
||||
- CSV: LF (`\n`) line endings; escape every cell via the existing `escapeCsvCell`.
|
||||
- Empty-member rule: **Subject members are always kept**; **Date and Parameter members are dropped when entirely blank** in the grid.
|
||||
- Group field ids: `__none__` (None), `__name__` (Name), `__id__` (Subject ID), otherwise a `subject_info` field id. Missing group value renders as `—`.
|
||||
- Default modal state must reproduce today's export exactly: Rows = Date, Columns = Subject, Fixed = Parameter (first parameter).
|
||||
- Context line is **beginning only** (no footer); two cells: pinned dimension label, pinned value.
|
||||
- Run tests from `frontend/`. Single file: `npx jest tests/dataExport.test.js`. Component file: `npx jest tests/ExportDataModal.test.jsx`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Dimension primitives + date members + status index
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/lib/dataExport.js` (add exports; keep existing `extractValue`, `listExportableParams`, `hasValue`, `dateKey`, `escapeCsvCell`, `csvFilename`)
|
||||
- Test: `frontend/tests/dataExport.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: existing `dateKey(date)` (private), `hasValue(v)` (private) in `dataExport.js`.
|
||||
- Produces:
|
||||
- `EXPORT_DIMENSIONS: Array<{ dim: 'date'|'subject'|'parameter', label: string }>`
|
||||
- `dimLabel(dim: string): string`
|
||||
- `otherDimension(rowDim: string, colDim: string): string` — the leftover dimension
|
||||
- `subjectGroupValue(animal: object, groupField: string): string|null` — `null` when `groupField` is falsy/`__none__`; `—` when the value is missing/empty
|
||||
- `listDateMembers(statuses: array): Array<{ id, label, dateKey }>` — sorted unique dateKeys
|
||||
- `buildStatusIndex(statuses: array): Map<string, Map<any, status>>` — `dateKey → animalId → status`, first-wins
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `frontend/tests/dataExport.test.js`:
|
||||
|
||||
```js
|
||||
import {
|
||||
EXPORT_DIMENSIONS, dimLabel, otherDimension, subjectGroupValue,
|
||||
listDateMembers, buildStatusIndex,
|
||||
} from '../src/lib/dataExport';
|
||||
|
||||
describe('dimension primitives', () => {
|
||||
it('exposes the three dimensions with labels', () => {
|
||||
expect(EXPORT_DIMENSIONS.map((d) => d.dim)).toEqual(['date', 'subject', 'parameter']);
|
||||
expect(dimLabel('date')).toBe('Date');
|
||||
expect(dimLabel('subject')).toBe('Subject');
|
||||
expect(dimLabel('parameter')).toBe('Parameter');
|
||||
});
|
||||
it('otherDimension returns the leftover', () => {
|
||||
expect(otherDimension('date', 'subject')).toBe('parameter');
|
||||
expect(otherDimension('parameter', 'date')).toBe('subject');
|
||||
});
|
||||
});
|
||||
|
||||
describe('subjectGroupValue', () => {
|
||||
const animal = { animal_name: 'Alpha', animal_id_string: 'R-001', subject_info: { sex: 'M', cohort: '' } };
|
||||
it('returns null when no group field', () => {
|
||||
expect(subjectGroupValue(animal, '__none__')).toBeNull();
|
||||
expect(subjectGroupValue(animal, '')).toBeNull();
|
||||
});
|
||||
it('resolves name / id / subject_info fields', () => {
|
||||
expect(subjectGroupValue(animal, '__name__')).toBe('Alpha');
|
||||
expect(subjectGroupValue(animal, '__id__')).toBe('R-001');
|
||||
expect(subjectGroupValue(animal, 'sex')).toBe('M');
|
||||
});
|
||||
it('renders missing or empty values as an em dash', () => {
|
||||
expect(subjectGroupValue(animal, 'cohort')).toBe('—');
|
||||
expect(subjectGroupValue({}, 'sex')).toBe('—');
|
||||
});
|
||||
});
|
||||
|
||||
describe('listDateMembers', () => {
|
||||
it('returns sorted unique dateKeys with id/label', () => {
|
||||
const statuses = [
|
||||
{ date: '2026-07-02T00:00:00.000Z' },
|
||||
{ date: '2026-07-01T12:00:00.000Z' },
|
||||
{ date: '2026-07-02T09:00:00.000Z' },
|
||||
];
|
||||
expect(listDateMembers(statuses)).toEqual([
|
||||
{ id: '2026-07-01', label: '2026-07-01', dateKey: '2026-07-01' },
|
||||
{ id: '2026-07-02', label: '2026-07-02', dateKey: '2026-07-02' },
|
||||
]);
|
||||
});
|
||||
it('tolerates empty input', () => {
|
||||
expect(listDateMembers(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildStatusIndex', () => {
|
||||
it('indexes by dateKey then animalId, first status wins', () => {
|
||||
const statuses = [
|
||||
{ animal_id: 'a1', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 7 } },
|
||||
{ animal_id: 'a1', date: '2026-07-01T06:00:00.000Z', analysis_summary: { total: 99 } },
|
||||
{ animal_id: 'a2', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 3 } },
|
||||
];
|
||||
const idx = buildStatusIndex(statuses);
|
||||
expect(idx.get('2026-07-01').get('a1').analysis_summary.total).toBe(7);
|
||||
expect(idx.get('2026-07-01').get('a2').analysis_summary.total).toBe(3);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npx jest tests/dataExport.test.js -t "dimension primitives"`
|
||||
Expected: FAIL — `EXPORT_DIMENSIONS is not defined` / imports undefined.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Add to `frontend/src/lib/dataExport.js` (after the existing `listExportableParams`, before `hasValue`):
|
||||
|
||||
```js
|
||||
export const EXPORT_DIMENSIONS = [
|
||||
{ dim: 'date', label: 'Date' },
|
||||
{ dim: 'subject', label: 'Subject' },
|
||||
{ dim: 'parameter', label: 'Parameter' },
|
||||
];
|
||||
|
||||
export function dimLabel(dim) {
|
||||
return EXPORT_DIMENSIONS.find((d) => d.dim === dim)?.label ?? '';
|
||||
}
|
||||
|
||||
export function otherDimension(rowDim, colDim) {
|
||||
return EXPORT_DIMENSIONS.map((d) => d.dim).find((d) => d !== rowDim && d !== colDim) ?? null;
|
||||
}
|
||||
|
||||
// Resolve a subject's group value for a given group field id.
|
||||
// null -> no grouping (field is falsy or '__none__')
|
||||
// '—' -> grouping is on but this subject has no value
|
||||
export function subjectGroupValue(animal, groupField) {
|
||||
if (!groupField || groupField === '__none__') return null;
|
||||
if (groupField === '__name__') return animal?.animal_name ?? '—';
|
||||
if (groupField === '__id__') return animal?.animal_id_string ?? '—';
|
||||
const v = animal?.subject_info?.[groupField];
|
||||
return v === null || v === undefined || v === '' ? '—' : v;
|
||||
}
|
||||
|
||||
export function listDateMembers(statuses) {
|
||||
const keys = new Set();
|
||||
for (const s of statuses ?? []) {
|
||||
if (s?.date) keys.add(dateKey(s.date));
|
||||
}
|
||||
return [...keys].sort().map((k) => ({ id: k, label: k, dateKey: k }));
|
||||
}
|
||||
|
||||
// dateKey -> Map(animalId -> status); first status per (date, animal) wins,
|
||||
// scanning in date order (matches the legacy buildMatrix tie-break).
|
||||
export function buildStatusIndex(statuses) {
|
||||
const index = new Map();
|
||||
const ordered = [...(statuses ?? [])].sort((a, b) => dateKey(a?.date).localeCompare(dateKey(b?.date)));
|
||||
for (const s of ordered) {
|
||||
if (!s?.date || s.animal_id === null || s.animal_id === undefined) continue;
|
||||
const dk = dateKey(s.date);
|
||||
if (!index.has(dk)) index.set(dk, new Map());
|
||||
const byAnimal = index.get(dk);
|
||||
if (!byAnimal.has(s.animal_id)) byAnimal.set(s.animal_id, s);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `npx jest tests/dataExport.test.js`
|
||||
Expected: PASS (new suites green, all pre-existing suites still green).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/lib/dataExport.js frontend/tests/dataExport.test.js
|
||||
git commit -m "feat(export): dimension primitives, date members, status index"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Subject + parameter members, dimension dispatcher
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/lib/dataExport.js`
|
||||
- Test: `frontend/tests/dataExport.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `subjectGroupValue`, `listDateMembers`, `listExportableParams` (existing).
|
||||
- Produces:
|
||||
- `listSubjectMembers(animals, groupField): Array<{ id, label, animalId, group }>` — headers disambiguated by `animal_id_string` on duplicate names; clustered by group value then sorted by name/header when grouping is on, else name/header only.
|
||||
- `listParameterMembers(dailyTemplate, statuses): Array<{ id, label, param, group }>` — wraps `listExportableParams`.
|
||||
- `listDimensionMembers(dim, { statuses, animals, dailyTemplate, groupField }): Array<member>`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `frontend/tests/dataExport.test.js`:
|
||||
|
||||
```js
|
||||
import { listSubjectMembers, listParameterMembers, listDimensionMembers } from '../src/lib/dataExport';
|
||||
|
||||
describe('listSubjectMembers', () => {
|
||||
const animals = [
|
||||
{ id: 'a2', animal_name: 'Beta', animal_id_string: 'R-002', subject_info: { grp: 'Drug' } },
|
||||
{ id: 'a1', animal_name: 'Alpha', animal_id_string: 'R-001', subject_info: { grp: 'Control' } },
|
||||
{ id: 'a3', animal_name: 'Gamma', animal_id_string: 'R-003', subject_info: { grp: 'Control' } },
|
||||
];
|
||||
it('orders by name and carries group=null when no group field', () => {
|
||||
const m = listSubjectMembers(animals, '__none__');
|
||||
expect(m.map((s) => s.label)).toEqual(['Alpha', 'Beta', 'Gamma']);
|
||||
expect(m.every((s) => s.group === null)).toBe(true);
|
||||
expect(m[0]).toMatchObject({ id: 'a1', animalId: 'a1' });
|
||||
});
|
||||
it('clusters by group value then name when grouping is on', () => {
|
||||
const m = listSubjectMembers(animals, 'grp');
|
||||
expect(m.map((s) => [s.group, s.label])).toEqual([
|
||||
['Control', 'Alpha'], ['Control', 'Gamma'], ['Drug', 'Beta'],
|
||||
]);
|
||||
});
|
||||
it('disambiguates duplicate names with the id string', () => {
|
||||
const dup = [
|
||||
{ id: 'a1', animal_name: 'Rat', animal_id_string: 'R-001' },
|
||||
{ id: 'a2', animal_name: 'Rat', animal_id_string: 'R-002' },
|
||||
];
|
||||
expect(listSubjectMembers(dup, '__none__').map((s) => s.label)).toEqual(['Rat (R-001)', 'Rat (R-002)']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listParameterMembers / listDimensionMembers', () => {
|
||||
const dailyTemplate = [{ fieldId: 'b-vitals', key: 'vitals', label: 'Vitals', builtin: true, active: true }];
|
||||
const statuses = [{ date: '2026-07-01T00:00:00.000Z', animal_id: 'a1', analysis_summary: { total: 1 } }];
|
||||
const animals = [{ id: 'a1', animal_name: 'Alpha', animal_id_string: 'R-001' }];
|
||||
it('wraps params with id/label/param/group', () => {
|
||||
const m = listParameterMembers(dailyTemplate, statuses);
|
||||
expect(m[0]).toMatchObject({ id: '__total__', label: 'Total attempts', group: 'metrics' });
|
||||
expect(m[0].param).toMatchObject({ kind: 'total' });
|
||||
});
|
||||
it('dispatches by dimension', () => {
|
||||
const ctx = { statuses, animals, dailyTemplate, groupField: '__none__' };
|
||||
expect(listDimensionMembers('date', ctx).map((d) => d.id)).toEqual(['2026-07-01']);
|
||||
expect(listDimensionMembers('subject', ctx).map((d) => d.id)).toEqual(['a1']);
|
||||
expect(listDimensionMembers('parameter', ctx)[0].id).toBe('__total__');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npx jest tests/dataExport.test.js -t "listSubjectMembers"`
|
||||
Expected: FAIL — `listSubjectMembers is not defined`.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Add to `frontend/src/lib/dataExport.js` (after `buildStatusIndex`):
|
||||
|
||||
```js
|
||||
export function listSubjectMembers(animals, groupField) {
|
||||
const list = [...(animals ?? [])];
|
||||
const nameCounts = new Map();
|
||||
for (const a of list) {
|
||||
const n = a?.animal_name ?? '';
|
||||
nameCounts.set(n, (nameCounts.get(n) ?? 0) + 1);
|
||||
}
|
||||
const members = list.map((a) => {
|
||||
const name = a?.animal_name ?? '';
|
||||
const label = nameCounts.get(name) > 1 ? `${name} (${a?.animal_id_string ?? ''})` : name;
|
||||
return { id: a.id, label, animalId: a.id, group: subjectGroupValue(a, groupField) };
|
||||
});
|
||||
const grouped = !!groupField && groupField !== '__none__';
|
||||
members.sort((x, y) => {
|
||||
if (grouped) {
|
||||
const g = String(x.group ?? '').localeCompare(String(y.group ?? ''));
|
||||
if (g !== 0) return g;
|
||||
}
|
||||
return String(x.label).localeCompare(String(y.label));
|
||||
});
|
||||
return members;
|
||||
}
|
||||
|
||||
export function listParameterMembers(dailyTemplate, statuses) {
|
||||
return listExportableParams(dailyTemplate, statuses).map((p) => ({
|
||||
id: p.id, label: p.label, param: p, group: p.group,
|
||||
}));
|
||||
}
|
||||
|
||||
export function listDimensionMembers(dim, { statuses, animals, dailyTemplate, groupField }) {
|
||||
if (dim === 'date') return listDateMembers(statuses);
|
||||
if (dim === 'subject') return listSubjectMembers(animals, groupField);
|
||||
if (dim === 'parameter') return listParameterMembers(dailyTemplate, statuses);
|
||||
return [];
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `npx jest tests/dataExport.test.js`
|
||||
Expected: PASS (all suites green).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/lib/dataExport.js frontend/tests/dataExport.test.js
|
||||
git commit -m "feat(export): subject/parameter members + dimension dispatcher"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Generalized buildMatrix
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/lib/dataExport.js` (replace the existing `buildMatrix`)
|
||||
- Test: `frontend/tests/dataExport.test.js` (replace the existing `describe('buildMatrix', ...)` block)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `buildStatusIndex`, `listDimensionMembers`, `otherDimension`, `dimLabel`, `extractValue`, `hasValue`.
|
||||
- Produces:
|
||||
`buildMatrix(config, ctx)` where
|
||||
- `config = { rowDim, colDim, pinnedMember, groupField }`
|
||||
- `ctx = { statuses, animals, dailyTemplate }`
|
||||
- returns `{ rowDim, colDim, pinnedDim, corner: string, context: { label, value }, groupAxis: 'row'|'col'|null, columns: Array<{ id, label, group }>, rows: Array<{ member: { id, label, group }, values: Array<value|''> }> }`
|
||||
- Empty-member rule: subject members always kept; date/parameter rows and columns dropped when entirely blank.
|
||||
- `groupAxis`: `'row'` when `rowDim === 'subject'` and grouping on; `'col'` when `colDim === 'subject'` and grouping on; else `null`.
|
||||
|
||||
- [ ] **Step 1: Replace the failing tests**
|
||||
|
||||
In `frontend/tests/dataExport.test.js`, **delete the entire existing `describe('buildMatrix', ...)` block** (the one using the old `buildMatrix(statuses, animals, totalParam)` signature) and the `const totalParam = ...` line, then add:
|
||||
|
||||
```js
|
||||
const gAnimals = [
|
||||
{ id: 'a2', animal_name: 'Beta', animal_id_string: 'R-002', subject_info: { grp: 'Drug' } },
|
||||
{ id: 'a1', animal_name: 'Alpha', animal_id_string: 'R-001', subject_info: { grp: 'Control' } },
|
||||
];
|
||||
const gStatuses = [
|
||||
{ animal_id: 'a1', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 5, success_rate: 0.5 } },
|
||||
{ animal_id: 'a2', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 9, success_rate: 0.9 } },
|
||||
{ animal_id: 'a1', date: '2026-07-02T00:00:00.000Z', analysis_summary: { total: 7, success_rate: 0.7 } },
|
||||
];
|
||||
const ctx = { statuses: gStatuses, animals: gAnimals, dailyTemplate: [] };
|
||||
const totalMember = { id: '__total__', label: 'Total attempts', param: { kind: 'total' }, group: 'metrics' };
|
||||
|
||||
describe('buildMatrix', () => {
|
||||
it('default preset: rows=date, cols=subject, pinned=parameter (legacy layout)', () => {
|
||||
const m = buildMatrix({ rowDim: 'date', colDim: 'subject', pinnedMember: totalMember, groupField: '__none__' }, ctx);
|
||||
expect(m.corner).toBe('Date');
|
||||
expect(m.context).toEqual({ label: 'Parameter', value: 'Total attempts' });
|
||||
expect(m.columns.map((c) => c.label)).toEqual(['Alpha', 'Beta']);
|
||||
expect(m.rows.map((r) => r.member.label)).toEqual(['2026-07-01', '2026-07-02']);
|
||||
expect(m.rows[0].values).toEqual([5, 9]);
|
||||
expect(m.rows[1].values).toEqual([7, '']); // Beta blank on 07-02
|
||||
expect(m.groupAxis).toBeNull();
|
||||
});
|
||||
|
||||
it('drops blank date rows but keeps all subject columns', () => {
|
||||
const statuses = [
|
||||
{ animal_id: 'a1', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 0 } }, // 0 is a value
|
||||
{ animal_id: 'a1', date: '2026-07-03T00:00:00.000Z', analysis_summary: null }, // no value
|
||||
];
|
||||
const m = buildMatrix({ rowDim: 'date', colDim: 'subject', pinnedMember: totalMember, groupField: '__none__' },
|
||||
{ statuses, animals: gAnimals, dailyTemplate: [] });
|
||||
expect(m.rows.map((r) => r.member.label)).toEqual(['2026-07-01']);
|
||||
expect(m.columns.length).toBe(2);
|
||||
expect(m.rows[0].values).toEqual([0, '']);
|
||||
});
|
||||
|
||||
it('subject as columns sets groupAxis=col and clusters subjects', () => {
|
||||
const m = buildMatrix({ rowDim: 'date', colDim: 'subject', pinnedMember: totalMember, groupField: 'grp' }, ctx);
|
||||
expect(m.groupAxis).toBe('col');
|
||||
expect(m.columns.map((c) => [c.group, c.label])).toEqual([['Control', 'Alpha'], ['Drug', 'Beta']]);
|
||||
});
|
||||
|
||||
it('subject as rows sets groupAxis=row; pinned date; cols=parameter', () => {
|
||||
const dateMember = { id: '2026-07-01', label: '2026-07-01', dateKey: '2026-07-01' };
|
||||
const m = buildMatrix({ rowDim: 'subject', colDim: 'parameter', pinnedMember: dateMember, groupField: 'grp' },
|
||||
{ statuses: gStatuses, animals: gAnimals, dailyTemplate: [] });
|
||||
expect(m.corner).toBe('Subject');
|
||||
expect(m.context).toEqual({ label: 'Date', value: '2026-07-01' });
|
||||
expect(m.groupAxis).toBe('row');
|
||||
expect(m.rows.map((r) => [r.member.group, r.member.label])).toEqual([['Control', 'Alpha'], ['Drug', 'Beta']]);
|
||||
// columns are parameters (Total attempts, Success rate); Alpha on 07-01: total 5, rate 0.5
|
||||
const totalCol = m.columns.findIndex((c) => c.label === 'Total attempts');
|
||||
expect(m.rows[0].values[totalCol]).toBe(5);
|
||||
});
|
||||
|
||||
it('drops blank parameter columns (params always dropped when empty)', () => {
|
||||
// dailyTemplate adds a custom field with no data anywhere -> its column drops
|
||||
const dailyTemplate = [{ fieldId: 'c-x', key: 'x', label: 'X', builtin: false, active: true }];
|
||||
const dateMember = { id: '2026-07-01', label: '2026-07-01', dateKey: '2026-07-01' };
|
||||
const m = buildMatrix({ rowDim: 'subject', colDim: 'parameter', pinnedMember: dateMember, groupField: '__none__' },
|
||||
{ statuses: gStatuses, animals: gAnimals, dailyTemplate });
|
||||
expect(m.columns.map((c) => c.label)).not.toContain('X');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npx jest tests/dataExport.test.js -t "buildMatrix"`
|
||||
Expected: FAIL — new call shape not yet implemented (old `buildMatrix` returns `columns[].header`, no `context`).
|
||||
|
||||
- [ ] **Step 3: Replace the implementation**
|
||||
|
||||
In `frontend/src/lib/dataExport.js`, **delete the old `buildMatrix` function** (the `export function buildMatrix(statuses, animals, param) { ... }` block and its doc comment) and replace with:
|
||||
|
||||
```js
|
||||
// Pivot statuses into a 2-D matrix per the chosen row/column dimensions.
|
||||
// The leftover (pinned) dimension is fixed to pinnedMember. Every cell resolves
|
||||
// a (dateKey, animalId, param) triple through the status index.
|
||||
export function buildMatrix(config, ctx) {
|
||||
const { rowDim, colDim, pinnedMember, groupField } = config;
|
||||
const pinnedDim = otherDimension(rowDim, colDim);
|
||||
const index = buildStatusIndex(ctx.statuses);
|
||||
const full = { ...ctx, groupField };
|
||||
const rowMembers = listDimensionMembers(rowDim, full);
|
||||
const colMembers = listDimensionMembers(colDim, full);
|
||||
|
||||
const cellValue = (rowM, colM) => {
|
||||
const byDim = { [rowDim]: rowM, [colDim]: colM, [pinnedDim]: pinnedMember };
|
||||
const dk = byDim.date?.dateKey ?? null;
|
||||
const animalId = byDim.subject?.animalId ?? null;
|
||||
const param = byDim.parameter?.param ?? null;
|
||||
if (dk === null || animalId === null || animalId === undefined || !param) return '';
|
||||
const status = index.get(dk)?.get(animalId);
|
||||
if (!status) return '';
|
||||
const v = extractValue(status, param);
|
||||
return hasValue(v) ? v : '';
|
||||
};
|
||||
|
||||
let rows = rowMembers.map((rowM) => ({
|
||||
member: rowM,
|
||||
values: colMembers.map((colM) => cellValue(rowM, colM)),
|
||||
}));
|
||||
|
||||
// Empty-member rule: keep all subjects; drop entirely-blank date/parameter rows & columns.
|
||||
if (rowDim !== 'subject') rows = rows.filter((r) => r.values.some((v) => v !== ''));
|
||||
|
||||
let keptCols = colMembers.map((_, i) => i);
|
||||
if (colDim !== 'subject') keptCols = keptCols.filter((i) => rows.some((r) => r.values[i] !== ''));
|
||||
const columns = keptCols.map((i) => colMembers[i]);
|
||||
rows = rows.map((r) => ({ member: r.member, values: keptCols.map((i) => r.values[i]) }));
|
||||
|
||||
const grouped = !!groupField && groupField !== '__none__';
|
||||
const groupAxis = grouped
|
||||
? (rowDim === 'subject' ? 'row' : colDim === 'subject' ? 'col' : null)
|
||||
: null;
|
||||
|
||||
return {
|
||||
rowDim,
|
||||
colDim,
|
||||
pinnedDim,
|
||||
corner: dimLabel(rowDim),
|
||||
context: { label: dimLabel(pinnedDim), value: pinnedMember?.label ?? '' },
|
||||
groupAxis,
|
||||
columns,
|
||||
rows,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `npx jest tests/dataExport.test.js`
|
||||
Expected: PASS. (The `toCSV` suite still passes — it builds its own matrix literals and does not call `buildMatrix`; it is rewritten in Task 4.)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/lib/dataExport.js frontend/tests/dataExport.test.js
|
||||
git commit -m "feat(export): generalized buildMatrix over assignable dimensions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: toCSV with context line + group cell
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/lib/dataExport.js` (replace `toCSV`; leave `csvFilename` unchanged)
|
||||
- Test: `frontend/tests/dataExport.test.js` (replace the existing `describe('toCSV', ...)` block)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `escapeCsvCell` (existing), matrix shape from Task 3.
|
||||
- Produces: `toCSV(matrix): string` — context line, blank line, optional group row (groupAxis==='col'), header row, data rows. Header/data gain a leading `Group` column when groupAxis==='row'. `csvFilename(title, label)` unchanged.
|
||||
|
||||
- [ ] **Step 1: Replace the failing tests**
|
||||
|
||||
In `frontend/tests/dataExport.test.js`, **delete the existing `describe('toCSV', ...)` block** and add:
|
||||
|
||||
```js
|
||||
describe('toCSV', () => {
|
||||
it('emits context line, blank line, header, data (no group)', () => {
|
||||
const matrix = {
|
||||
corner: 'Date',
|
||||
context: { label: 'Parameter', value: 'Total attempts' },
|
||||
groupAxis: null,
|
||||
columns: [{ id: 'a1', label: 'Alpha', group: null }, { id: 'a2', label: 'Beta', group: null }],
|
||||
rows: [{ member: { label: '2026-07-01' }, values: [5, ''] }, { member: { label: '2026-07-02' }, values: ['', 9] }],
|
||||
};
|
||||
expect(toCSV(matrix)).toBe(
|
||||
'Parameter,Total attempts\n\nDate,Alpha,Beta\n2026-07-01,5,\n2026-07-02,,9',
|
||||
);
|
||||
});
|
||||
|
||||
it('adds a Group header row when subjects are columns', () => {
|
||||
const matrix = {
|
||||
corner: 'Date',
|
||||
context: { label: 'Parameter', value: 'Total attempts' },
|
||||
groupAxis: 'col',
|
||||
columns: [{ id: 'a1', label: 'Alpha', group: 'Control' }, { id: 'a2', label: 'Beta', group: 'Drug' }],
|
||||
rows: [{ member: { label: '2026-07-01' }, values: [5, 9] }],
|
||||
};
|
||||
expect(toCSV(matrix)).toBe(
|
||||
'Parameter,Total attempts\n\nGroup,Control,Drug\nDate,Alpha,Beta\n2026-07-01,5,9',
|
||||
);
|
||||
});
|
||||
|
||||
it('adds a leading Group column when subjects are rows', () => {
|
||||
const matrix = {
|
||||
corner: 'Subject',
|
||||
context: { label: 'Date', value: '2026-07-01' },
|
||||
groupAxis: 'row',
|
||||
columns: [{ id: 'p1', label: 'Total attempts', group: 'metrics' }],
|
||||
rows: [
|
||||
{ member: { label: 'Alpha', group: 'Control' }, values: [5] },
|
||||
{ member: { label: 'Beta', group: 'Drug' }, values: [9] },
|
||||
],
|
||||
};
|
||||
expect(toCSV(matrix)).toBe(
|
||||
'Date,2026-07-01\n\nGroup,Subject,Total attempts\nControl,Alpha,5\nDrug,Beta,9',
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes commas, quotes, and newlines per RFC 4180', () => {
|
||||
const matrix = {
|
||||
corner: 'Date',
|
||||
context: { label: 'Parameter', value: 'Note, field' },
|
||||
groupAxis: null,
|
||||
columns: [{ id: 'a1', label: 'he said "hi"', group: null }],
|
||||
rows: [{ member: { label: '2026-07-01' }, values: ['line1\nline2'] }],
|
||||
};
|
||||
expect(toCSV(matrix)).toBe(
|
||||
'Parameter,"Note, field"\n\nDate,"he said ""hi"""\n2026-07-01,"line1\nline2"',
|
||||
);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npx jest tests/dataExport.test.js -t "toCSV"`
|
||||
Expected: FAIL — old `toCSV` emits `Date,Alpha,Beta\n...` with no context line.
|
||||
|
||||
- [ ] **Step 3: Replace the implementation**
|
||||
|
||||
In `frontend/src/lib/dataExport.js`, **delete the old `toCSV` function** and its doc comment, and replace with:
|
||||
|
||||
```js
|
||||
// Serialize a matrix to an RFC-4180-ish CSV string (LF line endings):
|
||||
// <pinned label>,<pinned value>
|
||||
// (blank line)
|
||||
// [Group,<group per subject column>] -- only when groupAxis === 'col'
|
||||
// <corner>[,Group?],<column headers...>
|
||||
// <row label>[,group?],<values...>
|
||||
export function toCSV(matrix) {
|
||||
const lines = [];
|
||||
lines.push([matrix.context.label, matrix.context.value].map(escapeCsvCell).join(','));
|
||||
lines.push('');
|
||||
|
||||
if (matrix.groupAxis === 'col') {
|
||||
const groupRow = ['Group', ...matrix.columns.map((c) => c.group ?? '—')];
|
||||
lines.push(groupRow.map(escapeCsvCell).join(','));
|
||||
}
|
||||
|
||||
const header = matrix.groupAxis === 'row'
|
||||
? ['Group', matrix.corner, ...matrix.columns.map((c) => c.label)]
|
||||
: [matrix.corner, ...matrix.columns.map((c) => c.label)];
|
||||
lines.push(header.map(escapeCsvCell).join(','));
|
||||
|
||||
for (const r of matrix.rows) {
|
||||
const lead = matrix.groupAxis === 'row'
|
||||
? [r.member.group ?? '—', r.member.label]
|
||||
: [r.member.label];
|
||||
lines.push([...lead, ...r.values].map(escapeCsvCell).join(','));
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `npx jest tests/dataExport.test.js`
|
||||
Expected: PASS (all suites, including the unchanged `csvFilename` and `extractValue` suites).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/lib/dataExport.js frontend/tests/dataExport.test.js
|
||||
git commit -m "feat(export): toCSV emits context line + subject group cell"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Rewrite ExportDataModal with axis + group controls
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/ExportDataModal.jsx` (full rewrite of the body; keep `triggerDownload`)
|
||||
- Test: `frontend/tests/ExportDataModal.test.jsx` (create)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `EXPORT_DIMENSIONS`, `dimLabel`, `otherDimension`, `listDimensionMembers`, `buildMatrix`, `toCSV`, `csvFilename` from `../lib/dataExport`; `experimentsApi.getDailyStatuses` from `../api/client`.
|
||||
- New props: `subjectTemplate` (array of `{ fieldId, label, active }`), `groupField` (string, the experiment's saved default). Existing props unchanged: `experimentTitle`, `experimentId`, `dailyTemplate`, `animals`, `onClose`.
|
||||
- Produces: rendered modal. Default selection reproduces the legacy layout.
|
||||
|
||||
- [ ] **Step 1: Write the failing component test**
|
||||
|
||||
Create `frontend/tests/ExportDataModal.test.jsx`:
|
||||
|
||||
```jsx
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import ExportDataModal from '../src/components/ExportDataModal';
|
||||
import * as client from '../src/api/client';
|
||||
|
||||
jest.mock('../src/api/client', () => ({
|
||||
experimentsApi: { getDailyStatuses: jest.fn() },
|
||||
}));
|
||||
|
||||
const animals = [
|
||||
{ id: 'a1', animal_name: 'Alpha', animal_id_string: 'R-001', subject_info: { grp: 'Control' } },
|
||||
{ id: 'a2', animal_name: 'Beta', animal_id_string: 'R-002', subject_info: { grp: 'Drug' } },
|
||||
];
|
||||
const statuses = [
|
||||
{ animal_id: 'a1', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 5 } },
|
||||
{ animal_id: 'a2', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 9 } },
|
||||
];
|
||||
const subjectTemplate = [{ fieldId: 'grp', label: 'Group', active: true }];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
client.experimentsApi.getDailyStatuses.mockResolvedValue(statuses);
|
||||
});
|
||||
|
||||
function renderModal(props = {}) {
|
||||
return render(
|
||||
<ExportDataModal
|
||||
experimentId="exp-1"
|
||||
experimentTitle="My Study"
|
||||
dailyTemplate={[]}
|
||||
animals={animals}
|
||||
subjectTemplate={subjectTemplate}
|
||||
groupField="__none__"
|
||||
onClose={jest.fn()}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ExportDataModal', () => {
|
||||
it('defaults to Rows=Date, Columns=Subject and shows the preview', async () => {
|
||||
renderModal();
|
||||
expect(await screen.findByLabelText('Rows')).toHaveValue('date');
|
||||
expect(screen.getByLabelText('Columns')).toHaveValue('subject');
|
||||
await waitFor(() => expect(screen.getByText(/1 date.*2 subjects/i)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('hides the Group by control until Subject is an axis', async () => {
|
||||
renderModal();
|
||||
await screen.findByLabelText('Rows');
|
||||
// subject is a column by default -> Group by visible
|
||||
expect(screen.getByLabelText('Group by')).toBeInTheDocument();
|
||||
// move subject off both axes: Rows=Date, Columns=Parameter -> subject pinned
|
||||
fireEvent.change(screen.getByLabelText('Columns'), { target: { value: 'parameter' } });
|
||||
await waitFor(() => expect(screen.queryByLabelText('Group by')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('keeps row and column dimensions distinct', async () => {
|
||||
renderModal();
|
||||
const rows = await screen.findByLabelText('Rows');
|
||||
fireEvent.change(rows, { target: { value: 'subject' } }); // collides with column=subject
|
||||
await waitFor(() => expect(screen.getByLabelText('Columns')).not.toHaveValue('subject'));
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npx jest tests/ExportDataModal.test.jsx`
|
||||
Expected: FAIL — modal has no `Rows`/`Columns`/`Group by` labels yet.
|
||||
|
||||
- [ ] **Step 3: Rewrite the component**
|
||||
|
||||
Replace the body of `frontend/src/components/ExportDataModal.jsx` (keep the top `triggerDownload` helper exactly as-is) with this component:
|
||||
|
||||
```jsx
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { experimentsApi } from '../api/client';
|
||||
import Button from './ui/Button';
|
||||
import Alert from './ui/Alert';
|
||||
import {
|
||||
EXPORT_DIMENSIONS, dimLabel, otherDimension,
|
||||
listDimensionMembers, buildMatrix, toCSV, csvFilename,
|
||||
} from '../lib/dataExport';
|
||||
|
||||
const PARAM_GROUP_LABELS = { metrics: 'Session metrics', daily: 'Daily record fields' };
|
||||
|
||||
export default function ExportDataModal({
|
||||
experimentTitle, experimentId, dailyTemplate, animals, subjectTemplate = [], groupField = '__none__', onClose,
|
||||
}) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [statuses, setStatuses] = useState([]);
|
||||
|
||||
const [rowDim, setRowDim] = useState('date');
|
||||
const [colDim, setColDim] = useState('subject');
|
||||
const [pinnedId, setPinnedId] = useState(null);
|
||||
const [groupBy, setGroupBy] = useState(groupField ?? '__none__');
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
experimentsApi.getDailyStatuses(experimentId, {})
|
||||
.then((data) => { if (alive) setStatuses(data); })
|
||||
.catch((err) => { if (alive) setError(err.message); })
|
||||
.finally(() => { if (alive) setLoading(false); });
|
||||
return () => { alive = false; };
|
||||
}, [experimentId]);
|
||||
|
||||
const pinnedDim = otherDimension(rowDim, colDim);
|
||||
const subjectIsAxis = rowDim === 'subject' || colDim === 'subject';
|
||||
const effGroup = subjectIsAxis ? groupBy : '__none__';
|
||||
const ctx = useMemo(() => ({ statuses, animals, dailyTemplate }), [statuses, animals, dailyTemplate]);
|
||||
|
||||
// Members of the pinned dimension populate its value dropdown.
|
||||
const pinnedMembers = useMemo(
|
||||
() => listDimensionMembers(pinnedDim, { ...ctx, groupField: effGroup }),
|
||||
[pinnedDim, ctx, effGroup],
|
||||
);
|
||||
const effPinnedId = pinnedId ?? pinnedMembers[0]?.id ?? null;
|
||||
const pinnedMember = pinnedMembers.find((m) => m.id === effPinnedId) ?? pinnedMembers[0] ?? null;
|
||||
|
||||
const matrix = useMemo(
|
||||
() => (pinnedMember
|
||||
? buildMatrix({ rowDim, colDim, pinnedMember, groupField: effGroup }, ctx)
|
||||
: { columns: [], rows: [], context: { label: '', value: '' }, corner: '', groupAxis: null }),
|
||||
[rowDim, colDim, pinnedMember, effGroup, ctx],
|
||||
);
|
||||
const hasData = matrix.rows.length > 0;
|
||||
|
||||
// When the user changes an axis, keep the two distinct and reset the pinned pick.
|
||||
function changeRowDim(next) {
|
||||
setRowDim(next);
|
||||
if (colDim === next) setColDim(otherDimension(next, null) ?? EXPORT_DIMENSIONS.find((d) => d.dim !== next).dim);
|
||||
setPinnedId(null);
|
||||
}
|
||||
function changeColDim(next) {
|
||||
setColDim(next);
|
||||
if (rowDim === next) setRowDim(EXPORT_DIMENSIONS.find((d) => d.dim !== next).dim);
|
||||
setPinnedId(null);
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
if (!hasData || !pinnedMember) return;
|
||||
triggerDownload(csvFilename(experimentTitle, pinnedMember.label), toCSV(matrix));
|
||||
onClose();
|
||||
}
|
||||
|
||||
if (loading) return <p className="text-sm text-gray-400" aria-live="polite">Loading data…</p>;
|
||||
if (error) return (
|
||||
<div className="space-y-4">
|
||||
<Alert type="error" message={error} />
|
||||
<div className="flex justify-end"><Button variant="secondary" onClick={onClose}>Close</Button></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const selectCls = 'w-full border border-gray-200 rounded px-2 py-1.5 text-sm bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400';
|
||||
const colOptions = EXPORT_DIMENSIONS.filter((d) => d.dim !== rowDim);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
Choose what goes on the rows and columns. The remaining dimension is fixed to one value, shown at the top of the file.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label htmlFor="export-rows" className="block text-xs font-medium text-gray-500 mb-1">Rows</label>
|
||||
<select id="export-rows" value={rowDim} onChange={(e) => changeRowDim(e.target.value)} className={selectCls}>
|
||||
{EXPORT_DIMENSIONS.map((d) => <option key={d.dim} value={d.dim}>{d.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="export-cols" className="block text-xs font-medium text-gray-500 mb-1">Columns</label>
|
||||
<select id="export-cols" value={colDim} onChange={(e) => changeColDim(e.target.value)} className={selectCls}>
|
||||
{colOptions.map((d) => <option key={d.dim} value={d.dim}>{d.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="export-pinned" className="block text-xs font-medium text-gray-500 mb-1">
|
||||
Fixed: {dimLabel(pinnedDim)}
|
||||
</label>
|
||||
<select
|
||||
id="export-pinned"
|
||||
value={effPinnedId ?? ''}
|
||||
onChange={(e) => setPinnedId(e.target.value)}
|
||||
className={selectCls}
|
||||
>
|
||||
{pinnedDim === 'parameter'
|
||||
? groupParamMembers(pinnedMembers).map((g) => (
|
||||
<optgroup key={g.group} label={PARAM_GROUP_LABELS[g.group] ?? g.group}>
|
||||
{g.items.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
|
||||
</optgroup>
|
||||
))
|
||||
: pinnedMembers.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{subjectIsAxis && (
|
||||
<div>
|
||||
<label htmlFor="export-group" className="block text-xs font-medium text-gray-500 mb-1">Group by</label>
|
||||
<select id="export-group" value={groupBy} onChange={(e) => setGroupBy(e.target.value)} className={selectCls}>
|
||||
<option value="__none__">— None —</option>
|
||||
<option value="__name__">Name</option>
|
||||
<option value="__id__">Subject ID</option>
|
||||
{subjectTemplate.filter((f) => f.active).map((f) => (
|
||||
<option key={f.fieldId} value={f.fieldId}>{f.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-400">
|
||||
{hasData
|
||||
? `${matrix.rows.length} ${matrix.corner.toLowerCase()}${matrix.rows.length !== 1 ? 's' : ''} × ${matrix.columns.length} ${dimLabel(colDim).toLowerCase()}${matrix.columns.length !== 1 ? 's' : ''}`
|
||||
: 'No data for this selection yet.'}
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={handleExport} disabled={!hasData}>Export CSV</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Group parameter members by their .group for <optgroup> rendering, preserving order.
|
||||
function groupParamMembers(members) {
|
||||
const out = [];
|
||||
for (const m of members) {
|
||||
let g = out.find((x) => x.group === m.group);
|
||||
if (!g) { g = { group: m.group, items: [] }; out.push(g); }
|
||||
g.items.push(m);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
```
|
||||
|
||||
Note: the preview test matches `/1 date.*2 subjects/i` — the corner is `Date` (→ "date"/"dates") and `dimLabel(colDim)` is `Subject` (→ "subject"/"subjects"). Confirm the string reads "1 date × 2 subjects".
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npx jest tests/ExportDataModal.test.jsx`
|
||||
Expected: PASS (all three cases).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/ExportDataModal.jsx frontend/tests/ExportDataModal.test.jsx
|
||||
git commit -m "feat(export): modal gains assignable axes + group-by control"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Wire new props from ExperimentDetail
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/ExperimentDetail.jsx` (the `<ExportDataModal ... />` render, ~line 414)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `subjectTemplate` state and `groupField` state (both already present in `ExperimentDetail`).
|
||||
- Produces: passes `subjectTemplate={subjectTemplate}` and `groupField={groupField}` to `ExportDataModal`.
|
||||
|
||||
- [ ] **Step 1: Add the two props**
|
||||
|
||||
In `frontend/src/pages/ExperimentDetail.jsx`, change the `<ExportDataModal>` element to:
|
||||
|
||||
```jsx
|
||||
<ExportDataModal
|
||||
experimentId={id}
|
||||
experimentTitle={experiment.title}
|
||||
dailyTemplate={dailyTemplate}
|
||||
animals={animals}
|
||||
subjectTemplate={subjectTemplate}
|
||||
groupField={groupField}
|
||||
onClose={() => setShowExport(false)}
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the full frontend test suite**
|
||||
|
||||
Run: `npm test`
|
||||
Expected: PASS — full suite green, including `dataExport` and `ExportDataModal`.
|
||||
|
||||
- [ ] **Step 3: Manual smoke check**
|
||||
|
||||
Run: `npm run dev`, open an experiment with daily data, click **Export data**. Verify: default download matches the old date×subject×parameter layout; switching Rows/Columns updates the preview; the fixed-value dropdown lists the third dimension; a group-by choice adds the Group row/column and the top context line reads e.g. `Subject,Mouse-01`.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/pages/ExperimentDetail.jsx
|
||||
git commit -m "feat(export): pass subjectTemplate + saved group field to export modal"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
- **Spec coverage:** assignable axes (Tasks 1–3, 5) · pinned third dimension + value picker (Tasks 3, 5) · beginning-only context line (Task 4) · subject group cell on the subject axis, clustered by group (Tasks 2–4) · Group-by dropdown defaulting to saved field, shown only when Subject is an axis (Tasks 5–6) · keep-subjects/drop-blank-date-and-param rule (Task 3) · default reproduces legacy layout (Tasks 3, 5). All covered.
|
||||
- **Type consistency:** member shape `{ id, label, ... }` and matrix shape `{ context, corner, groupAxis, columns:[{id,label,group}], rows:[{member,values}] }` are used identically across Tasks 3–5.
|
||||
- **Out of scope (per spec):** multi-value stacking, footer repeat, experiment-level metadata block, persisting axis choices.
|
||||
@@ -0,0 +1,668 @@
|
||||
# Subject-Series Data Export Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the export modal's assignable-axes UI with three pickers — X field (rows), Data value (cells), and Group by — where columns are always the subjects, matching the cross-subject metrics plot.
|
||||
|
||||
**Architecture:** Add X-coordinate helpers to `dataExport.js` (`numericFieldVal`, `listSampleMembers`, `buildSampleIndex`) and a focused `buildSubjectSeriesMatrix` that emits the existing `toCSV` shape. Rewrite `ExportDataModal.jsx` to the three pickers. Then delete the now-dead assignable-axes machinery.
|
||||
|
||||
**Tech Stack:** React 18, plain ES modules, Jest + `@testing-library/react` (jsdom). No new dependencies.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-19-subject-series-export-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- `dataExport.js` stays pure: no React, no I/O.
|
||||
- Cell value semantics: `0` and `''` are **real** values; only genuinely-missing lookups render blank (`''`), via the existing `hasValue` + `extractValue`.
|
||||
- CSV: LF (`\n`) line endings; escape every cell via the existing `escapeCsvCell`.
|
||||
- **X = `Date`** → rows are distinct `YYYY-MM-DD` dates, sorted.
|
||||
- **X = `# Days Reach`** (`'__days__'`) → rows are `1, 2, 3, …`; the max over subjects of that subject's count of recorded statuses (a status must have `animal_id` and `date`). A cell for `(subject, N)` reads the subject's Nth recorded day, counting **all** records sorted ascending by date (per-status ordinal, Day 1 = earliest).
|
||||
- **X = daily field** (its `fieldId`) → rows are the distinct **numeric** values (`numericFieldVal`), sorted ascending; non-numeric excluded. A cell for `(subject, v)` reads that subject's first status (by date) whose field equals `v`.
|
||||
- Columns are **always** the subjects (`listSubjectMembers`), clustered by group then name. Entirely-blank sample rows are dropped; subject columns are always kept.
|
||||
- Group by defaults to the saved field (`groupField` prop), validated against `__none__`/`__name__`/`__id__`/active `subjectTemplate` fields; coerced to `__none__` when unset or stale.
|
||||
- CSV: line 1 is `Data,<metric label>`; blank line; a `Group,…` row only when grouping (missing group → `—`); header `= <X label>,<subject headers…>`; data rows `= <X value>,<values…>`. Filename via existing `csvFilename(title, dataLabel)`.
|
||||
- Default X = `Date`; default Data = first parameter (`Total attempts`).
|
||||
- Run tests from `frontend/`: `npx jest tests/dataExport.test.js`, `npx jest tests/ExportDataModal.test.jsx`, full `npm test`.
|
||||
- Two suites (`tests/ExperimentCalendar.test.jsx`, `tests/ExperimentDayView.test.jsx`, 31 tests) fail for a PRE-EXISTING, unrelated reason and must be left as-is; changes must introduce no new failures.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: X-coordinate helpers
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/lib/dataExport.js` (add three exports; keep everything else for now)
|
||||
- Test: `frontend/tests/dataExport.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: existing private `dateKey(date)`.
|
||||
- Produces:
|
||||
- `numericFieldVal(status, field): number|null` — `field.builtin ? status[field.key] : status.custom_fields[field.fieldId]`, `parseFloat`, non-numeric → null.
|
||||
- `listSampleMembers(statuses, xField, dailyTemplate): Array<{ id, label, sampleValue }>` — row members for the chosen X (`xField` is `'__date__'`, `'__days__'`, or a daily `fieldId`).
|
||||
- `buildSampleIndex(statuses, xField, dailyTemplate): Map<animalId, Map<sampleValue, status>>` — first status per `(subject, sampleValue)` wins, per-subject date-ordered.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `frontend/tests/dataExport.test.js`:
|
||||
|
||||
```js
|
||||
import { numericFieldVal, listSampleMembers, buildSampleIndex } from '../src/lib/dataExport';
|
||||
|
||||
const sDaily = [{ fieldId: 'c-w', key: 'w', label: 'Weight', builtin: false, active: true }];
|
||||
const sStatuses = [
|
||||
{ animal_id: 'a1', date: '2026-07-01T00:00:00Z', custom_fields: { 'c-w': '250' } },
|
||||
{ animal_id: 'a1', date: '2026-07-03T00:00:00Z', custom_fields: { 'c-w': '260' } },
|
||||
{ animal_id: 'a2', date: '2026-07-01T00:00:00Z', custom_fields: { 'c-w': '250' } },
|
||||
];
|
||||
|
||||
describe('numericFieldVal', () => {
|
||||
it('reads builtin via key and custom via fieldId, parsed to number', () => {
|
||||
expect(numericFieldVal({ weight: '250' }, { builtin: true, key: 'weight' })).toBe(250);
|
||||
expect(numericFieldVal({ custom_fields: { 'c-w': '12.5' } }, { builtin: false, fieldId: 'c-w' })).toBe(12.5);
|
||||
});
|
||||
it('returns null for non-numeric, missing, or no field', () => {
|
||||
expect(numericFieldVal({ custom_fields: { 'c-w': 'abc' } }, { builtin: false, fieldId: 'c-w' })).toBeNull();
|
||||
expect(numericFieldVal({}, { builtin: true, key: 'weight' })).toBeNull();
|
||||
expect(numericFieldVal({ weight: 1 }, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listSampleMembers', () => {
|
||||
it('date: distinct dates sorted', () => {
|
||||
expect(listSampleMembers(sStatuses, '__date__', sDaily).map((m) => m.sampleValue)).toEqual(['2026-07-01', '2026-07-03']);
|
||||
});
|
||||
it('# days reach: 1..max records per subject', () => {
|
||||
expect(listSampleMembers(sStatuses, '__days__', sDaily).map((m) => m.sampleValue)).toEqual([1, 2]);
|
||||
});
|
||||
it('daily field: distinct numeric values sorted ascending', () => {
|
||||
expect(listSampleMembers(sStatuses, 'c-w', sDaily).map((m) => m.sampleValue)).toEqual([250, 260]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSampleIndex', () => {
|
||||
it('date coord maps animalId -> dateKey -> status', () => {
|
||||
const idx = buildSampleIndex(sStatuses, '__date__', sDaily);
|
||||
expect(idx.get('a1').get('2026-07-03').date).toContain('2026-07-03');
|
||||
});
|
||||
it('# days reach assigns per-subject ordinals in date order', () => {
|
||||
const idx = buildSampleIndex(sStatuses, '__days__', sDaily);
|
||||
expect(idx.get('a1').get(1).date).toContain('2026-07-01');
|
||||
expect(idx.get('a1').get(2).date).toContain('2026-07-03');
|
||||
expect(idx.get('a2').get(1).date).toContain('2026-07-01');
|
||||
expect(idx.get('a2').has(2)).toBe(false);
|
||||
});
|
||||
it('daily field maps numeric value to first status by date', () => {
|
||||
const idx = buildSampleIndex(sStatuses, 'c-w', sDaily);
|
||||
expect(idx.get('a1').get(250).date).toContain('2026-07-01');
|
||||
expect(idx.get('a1').get(260).date).toContain('2026-07-03');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npx jest tests/dataExport.test.js -t "listSampleMembers"`
|
||||
Expected: FAIL — `listSampleMembers is not defined`.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Add to `frontend/src/lib/dataExport.js` (after `listExportableParams`, before the private `hasValue`/`dateKey` — note `dateKey` is defined lower in the file but hoisted as a function declaration, so referencing it here is fine):
|
||||
|
||||
```js
|
||||
// Numeric value of a daily field on one status (mirrors the metrics plot's helper).
|
||||
export 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 Number.isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
// Ordered row members for the chosen X coordinate.
|
||||
// xField: '__date__' | '__days__' | <daily fieldId>
|
||||
export function listSampleMembers(statuses, xField, dailyTemplate) {
|
||||
const list = statuses ?? [];
|
||||
if (xField === '__date__') {
|
||||
const keys = new Set();
|
||||
for (const s of list) if (s?.date) keys.add(dateKey(s.date));
|
||||
return [...keys].sort().map((k) => ({ id: k, label: k, sampleValue: k }));
|
||||
}
|
||||
if (xField === '__days__') {
|
||||
const counts = new Map();
|
||||
for (const s of list) {
|
||||
if (s?.animal_id == null || !s?.date) continue;
|
||||
counts.set(s.animal_id, (counts.get(s.animal_id) ?? 0) + 1);
|
||||
}
|
||||
const max = counts.size ? Math.max(...counts.values()) : 0;
|
||||
return Array.from({ length: max }, (_, i) => ({ id: String(i + 1), label: String(i + 1), sampleValue: i + 1 }));
|
||||
}
|
||||
const field = (dailyTemplate ?? []).find((f) => f.fieldId === xField);
|
||||
const vals = new Set();
|
||||
for (const s of list) {
|
||||
const v = numericFieldVal(s, field);
|
||||
if (v !== null) vals.add(v);
|
||||
}
|
||||
return [...vals].sort((a, b) => a - b).map((v) => ({ id: String(v), label: String(v), sampleValue: v }));
|
||||
}
|
||||
|
||||
// animalId -> Map(sampleValue -> status). First status per (subject, sampleValue) wins,
|
||||
// scanning each subject's statuses in ascending date order.
|
||||
export function buildSampleIndex(statuses, xField, dailyTemplate) {
|
||||
const bySubject = new Map();
|
||||
for (const s of statuses ?? []) {
|
||||
if (s?.animal_id == null || !s?.date) continue;
|
||||
if (!bySubject.has(s.animal_id)) bySubject.set(s.animal_id, []);
|
||||
bySubject.get(s.animal_id).push(s);
|
||||
}
|
||||
const field = xField !== '__date__' && xField !== '__days__'
|
||||
? (dailyTemplate ?? []).find((f) => f.fieldId === xField)
|
||||
: null;
|
||||
|
||||
const index = new Map();
|
||||
for (const [animalId, subjStatuses] of bySubject) {
|
||||
const ordered = [...subjStatuses].sort((a, b) => dateKey(a.date).localeCompare(dateKey(b.date)));
|
||||
const m = new Map();
|
||||
ordered.forEach((s, i) => {
|
||||
let key;
|
||||
if (xField === '__date__') key = dateKey(s.date);
|
||||
else if (xField === '__days__') key = i + 1;
|
||||
else { const v = numericFieldVal(s, field); if (v === null) return; key = v; }
|
||||
if (!m.has(key)) m.set(key, s);
|
||||
});
|
||||
index.set(animalId, m);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `npx jest tests/dataExport.test.js`
|
||||
Expected: PASS (new suites green; all pre-existing suites still green).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/lib/dataExport.js frontend/tests/dataExport.test.js
|
||||
git commit -m "feat(export): X-coordinate helpers (date, # days reach, daily field)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: buildSubjectSeriesMatrix
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/lib/dataExport.js` (add `buildSubjectSeriesMatrix` + private `xFieldLabel`)
|
||||
- Test: `frontend/tests/dataExport.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `listSubjectMembers`, `listSampleMembers`, `buildSampleIndex`, `extractValue`, private `hasValue`.
|
||||
- Produces: `buildSubjectSeriesMatrix({ xField, dataParam, groupField }, { statuses, animals, dailyTemplate })` → `{ corner, context: { label: 'Data', value }, groupAxis: 'col'|null, columns: [{ id, label, animalId, group }], rows: [{ member: { id, label, sampleValue }, values: [value|''] }] }` — the same shape the existing `toCSV` consumes (`groupAxis: 'col'` triggers its group-row branch).
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `frontend/tests/dataExport.test.js`:
|
||||
|
||||
```js
|
||||
import { buildSubjectSeriesMatrix } from '../src/lib/dataExport';
|
||||
|
||||
const ssAnimals = [
|
||||
{ id: 'a1', animal_name: 'Alpha', animal_id_string: 'R-001', subject_info: { grp: 'Control' } },
|
||||
{ id: 'a2', animal_name: 'Beta', animal_id_string: 'R-002', subject_info: { grp: 'Drug' } },
|
||||
];
|
||||
const ssStatuses = [
|
||||
{ animal_id: 'a1', date: '2026-07-01T00:00:00Z', analysis_summary: { total: 5 } },
|
||||
{ animal_id: 'a1', date: '2026-07-02T00:00:00Z', analysis_summary: { total: 7 } },
|
||||
{ animal_id: 'a2', date: '2026-07-01T00:00:00Z', analysis_summary: { total: 9 } },
|
||||
];
|
||||
const totalParam = { id: '__total__', label: 'Total attempts', kind: 'total', group: 'metrics' };
|
||||
|
||||
describe('buildSubjectSeriesMatrix', () => {
|
||||
it('date X: subjects as columns, date rows, blank where missing, context names the metric', () => {
|
||||
const m = buildSubjectSeriesMatrix(
|
||||
{ xField: '__date__', dataParam: totalParam, groupField: '__none__' },
|
||||
{ statuses: ssStatuses, animals: ssAnimals, dailyTemplate: [] },
|
||||
);
|
||||
expect(m.corner).toBe('Date');
|
||||
expect(m.context).toEqual({ label: 'Data', value: 'Total attempts' });
|
||||
expect(m.groupAxis).toBeNull();
|
||||
expect(m.columns.map((c) => c.label)).toEqual(['Alpha', 'Beta']);
|
||||
expect(m.rows.map((r) => r.member.label)).toEqual(['2026-07-01', '2026-07-02']);
|
||||
expect(m.rows[0].values).toEqual([5, 9]);
|
||||
expect(m.rows[1].values).toEqual([7, '']);
|
||||
});
|
||||
it('# days reach X: rows align each subject by day ordinal', () => {
|
||||
const m = buildSubjectSeriesMatrix(
|
||||
{ xField: '__days__', dataParam: totalParam, groupField: '__none__' },
|
||||
{ statuses: ssStatuses, animals: ssAnimals, dailyTemplate: [] },
|
||||
);
|
||||
expect(m.corner).toBe('# Days Reach');
|
||||
expect(m.rows.map((r) => r.member.label)).toEqual(['1', '2']);
|
||||
expect(m.rows[0].values).toEqual([5, 9]);
|
||||
expect(m.rows[1].values).toEqual([7, '']);
|
||||
});
|
||||
it('grouping sets groupAxis col and clusters subjects', () => {
|
||||
const m = buildSubjectSeriesMatrix(
|
||||
{ xField: '__date__', dataParam: totalParam, groupField: 'grp' },
|
||||
{ statuses: ssStatuses, animals: ssAnimals, dailyTemplate: [] },
|
||||
);
|
||||
expect(m.groupAxis).toBe('col');
|
||||
expect(m.columns.map((c) => [c.group, c.label])).toEqual([['Control', 'Alpha'], ['Drug', 'Beta']]);
|
||||
});
|
||||
it('drops entirely-blank rows', () => {
|
||||
const statuses = [
|
||||
{ animal_id: 'a1', date: '2026-07-01T00:00:00Z', analysis_summary: { total: 5 } },
|
||||
{ animal_id: 'a1', date: '2026-07-05T00:00:00Z', analysis_summary: null },
|
||||
];
|
||||
const m = buildSubjectSeriesMatrix(
|
||||
{ xField: '__date__', dataParam: totalParam, groupField: '__none__' },
|
||||
{ statuses, animals: ssAnimals, dailyTemplate: [] },
|
||||
);
|
||||
expect(m.rows.map((r) => r.member.label)).toEqual(['2026-07-01']);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npx jest tests/dataExport.test.js -t "buildSubjectSeriesMatrix"`
|
||||
Expected: FAIL — `buildSubjectSeriesMatrix is not defined`.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Add to `frontend/src/lib/dataExport.js` (after `buildSampleIndex`):
|
||||
|
||||
```js
|
||||
function xFieldLabel(xField, dailyTemplate) {
|
||||
if (xField === '__date__') return 'Date';
|
||||
if (xField === '__days__') return '# Days Reach';
|
||||
return (dailyTemplate ?? []).find((f) => f.fieldId === xField)?.label ?? '';
|
||||
}
|
||||
|
||||
// Subjects as columns, X-field values as rows, one Data value per cell.
|
||||
// Returns the { context, corner, groupAxis, columns, rows } shape toCSV consumes.
|
||||
export function buildSubjectSeriesMatrix({ xField, dataParam, groupField }, { statuses, animals, dailyTemplate }) {
|
||||
const columns = listSubjectMembers(animals, groupField);
|
||||
const rowMembers = listSampleMembers(statuses, xField, dailyTemplate);
|
||||
const index = buildSampleIndex(statuses, xField, dailyTemplate);
|
||||
|
||||
const cellValue = (sampleValue, animalId) => {
|
||||
const status = index.get(animalId)?.get(sampleValue);
|
||||
if (!status || !dataParam) return '';
|
||||
const v = extractValue(status, dataParam);
|
||||
return hasValue(v) ? v : '';
|
||||
};
|
||||
|
||||
let rows = rowMembers.map((rm) => ({
|
||||
member: rm,
|
||||
values: columns.map((c) => cellValue(rm.sampleValue, c.animalId)),
|
||||
}));
|
||||
rows = rows.filter((r) => r.values.some((v) => v !== '')); // drop entirely-blank sample rows
|
||||
|
||||
const grouped = !!groupField && groupField !== '__none__';
|
||||
return {
|
||||
corner: xFieldLabel(xField, dailyTemplate),
|
||||
context: { label: 'Data', value: dataParam?.label ?? '' },
|
||||
groupAxis: grouped ? 'col' : null,
|
||||
columns,
|
||||
rows,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `npx jest tests/dataExport.test.js`
|
||||
Expected: PASS (all suites green).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/lib/dataExport.js frontend/tests/dataExport.test.js
|
||||
git commit -m "feat(export): buildSubjectSeriesMatrix — subjects as columns over an X field"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Rewrite ExportDataModal to the three pickers
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/ExportDataModal.jsx` (full body rewrite; keep `triggerDownload`)
|
||||
- Test: `frontend/tests/ExportDataModal.test.jsx` (rewrite)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `listExportableParams`, `buildSubjectSeriesMatrix`, `toCSV`, `csvFilename` from `../lib/dataExport`; `experimentsApi.getDailyStatuses`.
|
||||
- Props unchanged: `experimentTitle`, `experimentId`, `dailyTemplate`, `animals`, `subjectTemplate` (default `[]`), `groupField` (default `'__none__'`), `onClose`.
|
||||
- Produces: modal with three selects — X (rows), Data (cells), Group by.
|
||||
|
||||
- [ ] **Step 1: Rewrite the component test**
|
||||
|
||||
Replace the entire contents of `frontend/tests/ExportDataModal.test.jsx` with:
|
||||
|
||||
```jsx
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import ExportDataModal from '../src/components/ExportDataModal';
|
||||
import * as client from '../src/api/client';
|
||||
|
||||
jest.mock('../src/api/client', () => ({
|
||||
experimentsApi: { getDailyStatuses: jest.fn() },
|
||||
}));
|
||||
|
||||
const animals = [
|
||||
{ id: 'a1', animal_name: 'Alpha', animal_id_string: 'R-001', subject_info: { grp: 'Control' } },
|
||||
{ id: 'a2', animal_name: 'Beta', animal_id_string: 'R-002', subject_info: { grp: 'Drug' } },
|
||||
];
|
||||
const statuses = [
|
||||
{ animal_id: 'a1', date: '2026-07-01T00:00:00Z', analysis_summary: { total: 5, success_rate: 0.5 } },
|
||||
{ animal_id: 'a1', date: '2026-07-02T00:00:00Z', analysis_summary: { total: 7, success_rate: 0.7 } },
|
||||
{ animal_id: 'a2', date: '2026-07-01T00:00:00Z', analysis_summary: { total: 9, success_rate: 0.9 } },
|
||||
];
|
||||
const subjectTemplate = [{ fieldId: 'grp', label: 'Group', active: true }];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
client.experimentsApi.getDailyStatuses.mockResolvedValue(statuses);
|
||||
});
|
||||
|
||||
// Intercept the Blob handed to URL.createObjectURL so tests can read the real CSV.
|
||||
// Also stub anchor.click() — jsdom treats a click on <a href="blob:..."> as an
|
||||
// unimplemented navigation that throws async and can flake a later test.
|
||||
function mockDownload() {
|
||||
let blob = null;
|
||||
const origCreate = global.URL.createObjectURL;
|
||||
const origRevoke = global.URL.revokeObjectURL;
|
||||
const origClick = window.HTMLAnchorElement.prototype.click;
|
||||
global.URL.createObjectURL = jest.fn((b) => { blob = b; return 'blob:mock'; });
|
||||
global.URL.revokeObjectURL = jest.fn();
|
||||
window.HTMLAnchorElement.prototype.click = jest.fn();
|
||||
return {
|
||||
getBlob: () => blob,
|
||||
restore: async () => {
|
||||
await new Promise((r) => setTimeout(r, 0)); // flush handleExport's setTimeout revoke
|
||||
global.URL.createObjectURL = origCreate;
|
||||
global.URL.revokeObjectURL = origRevoke;
|
||||
window.HTMLAnchorElement.prototype.click = origClick;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readBlobText(blob) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fr = new FileReader();
|
||||
fr.onload = () => resolve(fr.result);
|
||||
fr.onerror = reject;
|
||||
fr.readAsText(blob);
|
||||
});
|
||||
}
|
||||
|
||||
function renderModal(props = {}) {
|
||||
return render(
|
||||
<ExportDataModal
|
||||
experimentId="exp-1"
|
||||
experimentTitle="My Study"
|
||||
dailyTemplate={[]}
|
||||
animals={animals}
|
||||
subjectTemplate={subjectTemplate}
|
||||
groupField="__none__"
|
||||
onClose={jest.fn()}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ExportDataModal', () => {
|
||||
it('renders X / Data / Group by pickers; X defaults to Date', async () => {
|
||||
renderModal();
|
||||
expect(await screen.findByLabelText('X axis (rows)')).toHaveValue('__date__');
|
||||
expect(screen.getByLabelText('Data (cells)')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Group by')).toHaveValue('__none__');
|
||||
await waitFor(() => expect(screen.getByText(/2 rows × 2 subjects/i)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('default export (X=Date, Data=Total attempts) has subject columns, date rows, blank where missing', async () => {
|
||||
const dl = mockDownload();
|
||||
renderModal();
|
||||
await screen.findByLabelText('X axis (rows)');
|
||||
await waitFor(() => expect(screen.getByText(/2 rows/i)).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByText('Export CSV'));
|
||||
const text = await readBlobText(dl.getBlob());
|
||||
expect(text).toBe('Data,Total attempts\n\nDate,Alpha,Beta\n2026-07-01,5,9\n2026-07-02,7,');
|
||||
await dl.restore();
|
||||
});
|
||||
|
||||
it('switching X to # Days Reach makes rows the day ordinals', async () => {
|
||||
const dl = mockDownload();
|
||||
renderModal();
|
||||
fireEvent.change(await screen.findByLabelText('X axis (rows)'), { target: { value: '__days__' } });
|
||||
await waitFor(() => expect(screen.getByText(/2 rows/i)).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByText('Export CSV'));
|
||||
const text = await readBlobText(dl.getBlob());
|
||||
expect(text).toBe('Data,Total attempts\n\n# Days Reach,Alpha,Beta\n1,5,9\n2,7,');
|
||||
await dl.restore();
|
||||
});
|
||||
|
||||
it('a valid group field adds a Group row and clusters subjects', async () => {
|
||||
const dl = mockDownload();
|
||||
renderModal({ groupField: 'grp' });
|
||||
await screen.findByLabelText('X axis (rows)');
|
||||
await waitFor(() => expect(screen.getByText(/2 rows/i)).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByText('Export CSV'));
|
||||
const text = await readBlobText(dl.getBlob());
|
||||
expect(text).toMatch(/^Group,Control,Drug$/m);
|
||||
await dl.restore();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `npx jest tests/ExportDataModal.test.jsx`
|
||||
Expected: FAIL — old modal has no `X axis (rows)` / `Data (cells)` labels.
|
||||
|
||||
- [ ] **Step 3: Rewrite the component**
|
||||
|
||||
Replace the entire contents of `frontend/src/components/ExportDataModal.jsx` with:
|
||||
|
||||
```jsx
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { experimentsApi } from '../api/client';
|
||||
import Button from './ui/Button';
|
||||
import Alert from './ui/Alert';
|
||||
import { listExportableParams, buildSubjectSeriesMatrix, toCSV, csvFilename } from '../lib/dataExport';
|
||||
|
||||
const PARAM_GROUP_LABELS = { metrics: 'Session metrics', daily: 'Daily record fields' };
|
||||
|
||||
function triggerDownload(filename, text) {
|
||||
const blob = new Blob([text], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
|
||||
export default function ExportDataModal({
|
||||
experimentTitle, experimentId, dailyTemplate, animals, subjectTemplate = [], groupField = '__none__', onClose,
|
||||
}) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [statuses, setStatuses] = useState([]);
|
||||
|
||||
const [xField, setXField] = useState('__date__');
|
||||
const [dataId, setDataId] = useState(null);
|
||||
const [groupBy, setGroupBy] = useState(() => {
|
||||
const valid = groupField === '__none__' || groupField === '__name__' || groupField === '__id__'
|
||||
|| subjectTemplate.some((f) => f.active && f.fieldId === groupField);
|
||||
return valid ? groupField : '__none__';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
experimentsApi.getDailyStatuses(experimentId, {})
|
||||
.then((data) => { if (alive) setStatuses(data); })
|
||||
.catch((err) => { if (alive) setError(err.message); })
|
||||
.finally(() => { if (alive) setLoading(false); });
|
||||
return () => { alive = false; };
|
||||
}, [experimentId]);
|
||||
|
||||
const params = useMemo(() => listExportableParams(dailyTemplate, statuses), [dailyTemplate, statuses]);
|
||||
const effDataId = dataId ?? params[0]?.id ?? null;
|
||||
const dataParam = params.find((p) => p.id === effDataId) ?? params[0] ?? null;
|
||||
|
||||
const activeDaily = useMemo(() => (dailyTemplate ?? []).filter((f) => f.active), [dailyTemplate]);
|
||||
const xOptions = useMemo(() => [
|
||||
{ id: '__date__', label: 'Date' },
|
||||
{ id: '__days__', label: '# Days Reach' },
|
||||
...activeDaily.map((f) => ({ id: f.fieldId, label: f.label })),
|
||||
], [activeDaily]);
|
||||
|
||||
const matrix = useMemo(
|
||||
() => (dataParam
|
||||
? buildSubjectSeriesMatrix({ xField, dataParam, groupField: groupBy }, { statuses, animals, dailyTemplate })
|
||||
: { columns: [], rows: [], context: { label: 'Data', value: '' }, corner: '', groupAxis: null }),
|
||||
[xField, dataParam, groupBy, statuses, animals, dailyTemplate],
|
||||
);
|
||||
const hasData = matrix.rows.length > 0;
|
||||
|
||||
const groupedParams = useMemo(() => {
|
||||
const out = [];
|
||||
for (const p of params) {
|
||||
let g = out.find((x) => x.group === p.group);
|
||||
if (!g) { g = { group: p.group, items: [] }; out.push(g); }
|
||||
g.items.push(p);
|
||||
}
|
||||
return out;
|
||||
}, [params]);
|
||||
|
||||
function handleExport() {
|
||||
if (!hasData || !dataParam) return;
|
||||
triggerDownload(csvFilename(experimentTitle, dataParam.label), toCSV(matrix));
|
||||
onClose();
|
||||
}
|
||||
|
||||
if (loading) return <p className="text-sm text-gray-400" aria-live="polite">Loading data…</p>;
|
||||
if (error) return (
|
||||
<div className="space-y-4">
|
||||
<Alert type="error" message={error} />
|
||||
<div className="flex justify-end"><Button variant="secondary" onClick={onClose}>Close</Button></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const selectCls = 'w-full border border-gray-200 rounded px-2 py-1.5 text-sm bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400';
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
One value per subject over an X axis — each column is a subject, each row an X value. Blank where a subject has no value.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label htmlFor="export-x" className="block text-xs font-medium text-gray-500 mb-1">X axis (rows)</label>
|
||||
<select id="export-x" value={xField} onChange={(e) => setXField(e.target.value)} className={selectCls}>
|
||||
{xOptions.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="export-data" className="block text-xs font-medium text-gray-500 mb-1">Data (cells)</label>
|
||||
<select id="export-data" value={effDataId ?? ''} onChange={(e) => setDataId(e.target.value)} className={selectCls}>
|
||||
{groupedParams.map((g) => (
|
||||
<optgroup key={g.group} label={PARAM_GROUP_LABELS[g.group] ?? g.group}>
|
||||
{g.items.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="export-group" className="block text-xs font-medium text-gray-500 mb-1">Group by</label>
|
||||
<select id="export-group" value={groupBy} onChange={(e) => setGroupBy(e.target.value)} className={selectCls}>
|
||||
<option value="__none__">— None —</option>
|
||||
<option value="__name__">Name</option>
|
||||
<option value="__id__">Subject ID</option>
|
||||
{subjectTemplate.filter((f) => f.active).map((f) => (
|
||||
<option key={f.fieldId} value={f.fieldId}>{f.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400">
|
||||
{hasData
|
||||
? `${matrix.rows.length} row${matrix.rows.length !== 1 ? 's' : ''} × ${matrix.columns.length} subject${matrix.columns.length !== 1 ? 's' : ''}`
|
||||
: 'No data for this selection yet.'}
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={handleExport} disabled={!hasData}>Export CSV</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `npx jest tests/ExportDataModal.test.jsx`
|
||||
Expected: PASS (all four cases).
|
||||
|
||||
- [ ] **Step 5: Run the full suite**
|
||||
|
||||
Run: `npm test`
|
||||
Expected: `dataExport` + `ExportDataModal` green; only the two known pre-existing suites fail; no new failures.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/ExportDataModal.jsx frontend/tests/ExportDataModal.test.jsx
|
||||
git commit -m "feat(export): modal becomes X / Data / Group by pickers, subjects as columns"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Remove the dead assignable-axes code
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/lib/dataExport.js` (delete unused exports)
|
||||
- Test: `frontend/tests/dataExport.test.js` (delete their describe blocks + imports)
|
||||
|
||||
**Interfaces:**
|
||||
- Removes (now unused after Task 3): `EXPORT_DIMENSIONS`, `dimLabel`, `otherDimension`, `listDateMembers`, `buildStatusIndex`, `listParameterMembers`, `listDimensionMembers`, and `buildMatrix`.
|
||||
- Keeps: `extractValue`, `listExportableParams`, `subjectGroupValue`, `listSubjectMembers`, `numericFieldVal`, `listSampleMembers`, `buildSampleIndex`, `buildSubjectSeriesMatrix`, `toCSV`, `csvFilename`, and the private `hasValue`, `dateKey`, `escapeCsvCell`, `xFieldLabel`.
|
||||
|
||||
- [ ] **Step 1: Delete the dead source exports**
|
||||
|
||||
In `frontend/src/lib/dataExport.js`, delete these eight exported definitions entirely (they are only used by each other and the old modal, now replaced): `EXPORT_DIMENSIONS`, `dimLabel`, `otherDimension`, `listDateMembers`, `buildStatusIndex`, `listParameterMembers`, `listDimensionMembers`, `buildMatrix`. Also update the top-of-file comment to describe the subject-series export (drop the "dimension-agnostic buildMatrix" wording). Keep `dateKey`, `hasValue`, `escapeCsvCell`, and everything listed under "Keeps" above.
|
||||
|
||||
- [ ] **Step 2: Delete their tests**
|
||||
|
||||
In `frontend/tests/dataExport.test.js`, delete the describe blocks and their now-unused import identifiers for the removed functions: the `describe('dimension primitives', …)`, `describe('subjectGroupValue', …)` KEEP (subjectGroupValue is kept — do NOT delete it), `describe('listDateMembers', …)`, `describe('buildStatusIndex', …)`, `describe('listParameterMembers / listDimensionMembers', …)`, and `describe('buildMatrix', …)` blocks. Remove `EXPORT_DIMENSIONS`, `dimLabel`, `otherDimension`, `listDateMembers`, `buildStatusIndex`, `listDimensionMembers`, `listParameterMembers`, and `buildMatrix` from the file's `import` statements, leaving the imports for kept functions (`extractValue`, `listExportableParams`, `subjectGroupValue`, `listSubjectMembers`, `numericFieldVal`, `listSampleMembers`, `buildSampleIndex`, `buildSubjectSeriesMatrix`, `toCSV`, `csvFilename`).
|
||||
|
||||
- [ ] **Step 3: Verify nothing references the removed names**
|
||||
|
||||
Run: `grep -rnE "buildMatrix|EXPORT_DIMENSIONS|listDimensionMembers|buildStatusIndex|listDateMembers|listParameterMembers|otherDimension|dimLabel" frontend/src frontend/tests`
|
||||
Expected: no output (all references gone).
|
||||
|
||||
- [ ] **Step 4: Run the suites**
|
||||
|
||||
Run: `npx jest tests/dataExport.test.js tests/ExportDataModal.test.jsx`
|
||||
Expected: PASS. Then `npm test` — only the two known pre-existing suites fail, no new failures.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/lib/dataExport.js frontend/tests/dataExport.test.js
|
||||
git commit -m "refactor(export): remove dead assignable-axes helpers and their tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
- **Spec coverage:** three pickers (Task 3) · X = Date/# Days Reach/daily field with the exact semantics (Tasks 1–2) · subjects always columns, clustered by group, blank rows dropped (Task 2) · Group by validated/defaulted (Task 3) · CSV `Data,<metric>` + group row + X-corner (Tasks 2–3, reusing `toCSV`) · assignable-axes UI/machinery removed (Tasks 3–4). All covered.
|
||||
- **Type consistency:** sample member `{ id, label, sampleValue }`, subject column `{ id, label, animalId, group }`, and matrix `{ context, corner, groupAxis, columns, rows }` are used identically across Tasks 1–3 and match the existing `toCSV`.
|
||||
- **Out of scope (per spec):** assignable columns/pinned, subject subsetting, analyzed-only day counting, footer/repeat line.
|
||||
@@ -0,0 +1,235 @@
|
||||
# Numerical CSV-analysis buckets, persisted per animal
|
||||
|
||||
**Date:** 2026-05-01
|
||||
**Scope:** `frontend/src/components/CsvAnalysis.jsx`, `frontend/src/lib/csvAnalysis.js`, `frontend/src/api/client.js`, `frontend/src/pages/DailyStatusDetail.jsx`, `backend/prisma/schema.prisma`, backend animal route.
|
||||
|
||||
## Goal
|
||||
|
||||
In the daily-status CSV-analysis flow, let the user define an extra type of bucket — a **numerical** bucket — alongside the existing note-classification buckets (Success / Failure / Other / custom). A numerical bucket plots its assigned notes' numeric portion against the CSV frame column (or timestamp as fallback) and reports an average. The bucket structure and per-bucket note assignments persist on the `Animal` so subsequent CSV uploads for the same subject don't need to be re-classified from scratch.
|
||||
|
||||
## Section 0 — Preconditions
|
||||
|
||||
Before applying the schema migration, back up the live Postgres database to a sibling `archived/` folder.
|
||||
|
||||
```bash
|
||||
mkdir -p /home/sam/docker-images/archived
|
||||
docker exec experiments_postgres pg_dump -U expuser -d experiments_db -F c \
|
||||
> /home/sam/docker-images/archived/experiments_db_$(date +%Y%m%d_%H%M%S).dump
|
||||
```
|
||||
|
||||
- Verify the dump file size is greater than 0 bytes before proceeding.
|
||||
- Restore command (if needed):
|
||||
```bash
|
||||
docker exec -i experiments_postgres pg_restore -U expuser -d experiments_db -c \
|
||||
< /home/sam/docker-images/archived/<dumpfile>
|
||||
```
|
||||
|
||||
## Section 1 — Data model
|
||||
|
||||
### Schema change
|
||||
|
||||
Add one nullable JSON column to `Animal`:
|
||||
|
||||
```prisma
|
||||
model Animal {
|
||||
id String @id @default(uuid())
|
||||
experiment_id String
|
||||
animal_id_string String
|
||||
animal_name String
|
||||
subject_info Json?
|
||||
csv_analysis_config Json? // <— new
|
||||
experiment Experiment @relation(fields: [experiment_id], references: [id], onDelete: Cascade)
|
||||
daily_statuses DailyStatus[]
|
||||
|
||||
@@map("animals")
|
||||
}
|
||||
```
|
||||
|
||||
### Stored shape
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"buckets": [
|
||||
{ "name": "Success", "type": "note", "notes": ["S", "S1"] },
|
||||
{ "name": "Failure", "type": "note", "notes": ["F"] },
|
||||
{ "name": "Other", "type": "note", "notes": [] },
|
||||
{ "name": "Lick latency", "type": "numerical", "notes": ["L25", "L30"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
- `type: "note"` — existing behavior (counted, run-length-encoded, contributes to daily-status summary).
|
||||
- `type: "numerical"` — plotted vs. frame (or timestamp); reports average; **excluded** from category counts, consecutive runs, and the "Save metrics to daily status" calculation.
|
||||
- The three default note buckets (Success, Failure, Other) are always present: seeded into `buckets` on first save and undeletable in the UI.
|
||||
- Custom buckets (both note and numerical) can be removed via an `✕` control on the bucket. Removal puts any notes that were assigned to that bucket back into Unassigned for the current session and drops the bucket from `buckets[]` on next save.
|
||||
- `notes` arrays persist as raw trimmed strings. A new CSV upload looks up each unique note in the persisted `notes` arrays to auto-classify; any leftover notes appear as **Unassigned**.
|
||||
- A single note value belongs to at most one bucket. Assigning a note to bucket B removes it from any other bucket's `notes[]` first.
|
||||
- Bucket names are unique within `buckets`. Renaming is out of scope for v1.
|
||||
- `csv_analysis_config` may be `null` (animal never had CSV analysis run); UI falls back to `DEFAULT_BUCKETS` in that case.
|
||||
|
||||
### Backend API
|
||||
|
||||
- `GET /api/animals/:id` — already returns the full animal row. `csv_analysis_config` is included automatically.
|
||||
- `PATCH /api/animals/:id/csv-config` — body `{ buckets: [...] }`. Validates:
|
||||
- `buckets` is an array.
|
||||
- Each entry has non-empty trimmed `name`, `type ∈ {"note","numerical"}`, `notes` is `string[]`.
|
||||
- `name` values are unique within the array.
|
||||
- The three default note buckets (Success/Failure/Other) are present.
|
||||
Replaces the column atomically. Returns updated animal. Audit-logged via existing middleware.
|
||||
|
||||
## Section 2 — Frontend changes
|
||||
|
||||
### `CsvAnalysis.jsx` (the main refactor)
|
||||
|
||||
1. **New prop `animal`** — passed from `DailyStatusDetail`. Used to read & write `csv_analysis_config`.
|
||||
|
||||
2. **State refactor**: replace the parallel `categories: string[]` + `customCategories: string[]` + `classifications: { [note]: category }` with a single source of truth:
|
||||
```js
|
||||
const [buckets, setBuckets] = useState([]);
|
||||
// bucket: { name: string, type: 'note' | 'numerical', notes: string[] }
|
||||
```
|
||||
Derive `classifications`, `categories`, `customCategories` from `buckets` for compatibility with downstream code (`runAnalysis`, color helpers).
|
||||
|
||||
3. **Seeding on entering classify phase**:
|
||||
- If `animal.csv_analysis_config?.buckets` exists, use it.
|
||||
- Otherwise initialize from `DEFAULT_BUCKETS` (Success/Failure/Other, all `type: "note"`, empty `notes`).
|
||||
- Auto-classify: any `uniqueNote` whose value appears in some bucket's `notes[]` is considered already-assigned and does not appear in Unassigned.
|
||||
|
||||
4. **"+ Add field" button** (replaces today's "+ New category"):
|
||||
- Inline form: `<input name>` + radio toggle `( ) Note ( ) Numerical`.
|
||||
- On submit, appends `{ name, type, notes: [] }` to `buckets`.
|
||||
|
||||
5. **`CategoryBucket` extension**:
|
||||
- Accepts `type` prop.
|
||||
- Color allocation: `getCategoryColors` is updated to take the full bucket list and assign `EXTRA_COLORS` round-robin across all custom buckets (note + numerical combined), so adding numerical buckets does not shift the colors of existing note buckets.
|
||||
- Numerical buckets render with a "numerical" header badge to distinguish them visually.
|
||||
- Numerical buckets show an "**Add all unassigned**" button that bulk-moves every currently-unassigned unique note into this bucket's `notes[]`.
|
||||
- Custom buckets (note or numerical) show an `✕` remove control on hover.
|
||||
- In results phase, numerical buckets show a footer line: `Avg: 24.3 · 47/50 rows plottable`.
|
||||
|
||||
6. **`compute()`**:
|
||||
- Note-type buckets: pass through `runAnalysis(rows, timestampCol, noteCol, classifications)` as today, but `classifications` is built only from `note`-type buckets (numerical-bucket assignments are excluded so they don't pollute counts/runs).
|
||||
- Numerical-type buckets: for each, call new helper `computeNumericSeries(rows, frameCol || timestampCol, noteCol, bucket)` returning `{ points: [{x, value, raw}], avg, skipped, total, xLabel }`.
|
||||
- After successful analysis, call `animalsApi.updateCsvConfig(animal.id, { buckets })`. Failure surfaces a non-blocking toast/inline error; analysis results still render.
|
||||
|
||||
7. **Results phase additions**:
|
||||
- Existing tables (Category Counts, Consecutive Runs) iterate `buckets.filter(b => b.type === 'note')` — numerical buckets are excluded.
|
||||
- New "Numerical Plots" section after `RunSequenceView`. One panel per numerical bucket, containing:
|
||||
- Bucket name, average, skipped-count.
|
||||
- Recharts `<LineChart>` with `XAxis dataKey="x"` (label = `xLabel`) and a single `<Line dataKey="value">`. Color from `getCategoryColors(bucket.name, customCategories)`.
|
||||
- If a numerical bucket has zero plottable rows, render `"No plottable values — N of N note rows had no extractable number."` instead of a chart.
|
||||
|
||||
### `frontend/src/lib/csvAnalysis.js`
|
||||
|
||||
```js
|
||||
// Extract first signed decimal from a string. Returns null if none found.
|
||||
export function extractNumber(str) {
|
||||
if (str == null) return null;
|
||||
const m = String(str).match(/-?\d+(\.\d+)?/);
|
||||
return m ? parseFloat(m[0]) : null;
|
||||
}
|
||||
|
||||
// Build a numerical series for one bucket.
|
||||
// xColName: frameCol if set, else timestampCol. xLabel reflects which.
|
||||
export function computeNumericSeries(rows, xColName, noteCol, bucket) {
|
||||
const noteSet = new Set(bucket.notes);
|
||||
const points = [];
|
||||
let skipped = 0;
|
||||
let total = 0;
|
||||
|
||||
for (const r of rows) {
|
||||
const note = String(r[noteCol] ?? '').trim();
|
||||
if (!noteSet.has(note)) continue;
|
||||
total++;
|
||||
const value = extractNumber(note);
|
||||
const x = parseFloat(r[xColName]);
|
||||
if (value == null || Number.isNaN(x)) { skipped++; continue; }
|
||||
points.push({ x, value, raw: note });
|
||||
}
|
||||
|
||||
points.sort((a, b) => a.x - b.x);
|
||||
const avg = points.length === 0
|
||||
? null
|
||||
: points.reduce((s, p) => s + p.value, 0) / points.length;
|
||||
|
||||
return { points, avg, skipped, total, xLabel: xColName };
|
||||
}
|
||||
```
|
||||
|
||||
### `frontend/src/api/client.js`
|
||||
|
||||
Add to `animalsApi`:
|
||||
```js
|
||||
updateCsvConfig: (animalId, config) =>
|
||||
request(`/animals/${animalId}/csv-config`, { method: 'PATCH', body: JSON.stringify(config) }),
|
||||
```
|
||||
|
||||
### `frontend/src/pages/DailyStatusDetail.jsx`
|
||||
|
||||
Single change: pass `animal={status.animal}` to `<CsvAnalysis>`.
|
||||
|
||||
## Section 3 — Behavior details
|
||||
|
||||
- **Frame column** stays optional. If unset when running analysis with ≥1 numerical bucket, x-axis falls back to `timestampCol`. Chart axis label and the panel header reflect which axis is in use.
|
||||
- **Letter stripping** uses `/-?\d+(\.\d+)?/` (first signed decimal in the string). Notes with no extractable number are counted as `skipped`.
|
||||
- **Persistence trigger**: config is saved after `compute()` succeeds. Editing buckets in classify phase without running analysis does not persist.
|
||||
- **Multiple numerical buckets**: fully supported. Each gets its own chart and its own average.
|
||||
- **Same note in multiple buckets**: prevented at the UI level — assigning a note to a bucket removes it from any other bucket. Backend does not enforce this (UI invariant).
|
||||
|
||||
## Section 4 — Error handling
|
||||
|
||||
| Failure | Behavior |
|
||||
|-----------------------------------------------|-------------------------------------------------------------------------------------------|
|
||||
| Save config (PATCH) fails | Local state unchanged; non-blocking inline error in classify/results UI; analysis still renders. |
|
||||
| Numerical bucket has zero plottable rows | Panel shows "No plottable values — N of N rows had no extractable number." No crash. |
|
||||
| Frame column absent + numerical bucket exists | Falls back to timestamp; chart axis label reflects this. |
|
||||
| Migration | Run the Section 0 backup first; abort if dump file size is 0 bytes. |
|
||||
| Validation rejection on PATCH | UI shows server error message; user can re-edit and retry. |
|
||||
|
||||
## Section 5 — Testing
|
||||
|
||||
### Frontend unit (Vitest, `frontend/src/lib/`)
|
||||
|
||||
- `extractNumber()`:
|
||||
- `"L25.5"` → 25.5
|
||||
- `"R-12"` → -12
|
||||
- `"trial_007"` → 7
|
||||
- `"3.2e2"` → 3.2 (loses scientific notation — accepted limitation)
|
||||
- `"42 ms"` → 42
|
||||
- `"abc"` → null
|
||||
- `""` → null
|
||||
- `null` → null
|
||||
- `computeNumericSeries()`:
|
||||
- All rows valid → correct points + average + skipped=0
|
||||
- Some unparseable → correct skipped count, points exclude bad rows
|
||||
- All unparseable → empty points, avg=null, skipped=total
|
||||
- `xColName` missing from rows → all skipped (NaN x)
|
||||
- Bucket with empty `notes[]` → empty points, total=0
|
||||
- Output sorted ascending by x
|
||||
|
||||
### Backend unit
|
||||
|
||||
- `PATCH /animals/:id/csv-config`:
|
||||
- Valid body → 200, persisted, audit log row written
|
||||
- Empty bucket name → 400
|
||||
- Duplicate names → 400
|
||||
- Invalid `type` value → 400
|
||||
- `notes` not an array → 400
|
||||
- Missing default bucket (e.g. no "Success") → 400
|
||||
|
||||
### Integration / manual
|
||||
|
||||
- Upload a CSV → create one numerical bucket → assign notes → run analysis → reload page → upload same CSV → confirm bucket is pre-populated and assignments are restored.
|
||||
- Same flow with a CSV containing genuinely-new note values → confirm new notes show as Unassigned, old ones do not.
|
||||
- Numerical bucket with frame col present → chart x-axis labeled with frame col name.
|
||||
- Numerical bucket with frame col cleared → chart x-axis labeled with timestamp col name.
|
||||
|
||||
## Section 6 — Out of scope (v1)
|
||||
|
||||
- Bucket renaming after creation.
|
||||
- Y-axis range customization, multi-line overlays.
|
||||
- Showing the chart for past `DailyAnalysis` records (frame data isn't stored; user re-drops the CSV to plot).
|
||||
- Per-experiment defaults (config is per-animal only).
|
||||
- Editing config from a settings page; config is only edited via the CSV-analysis flow.
|
||||
@@ -0,0 +1,113 @@
|
||||
# Cross-subject metrics plot — interaction enhancements
|
||||
|
||||
**Date:** 2026-06-01
|
||||
**Component:** `frontend/src/components/AnalysisCharts.jsx` → `ExperimentAnalysisCharts`
|
||||
**Rendered on:** `frontend/src/pages/ExperimentDetail.jsx`
|
||||
|
||||
## Summary
|
||||
|
||||
Add four client-side interaction features to the "Cross-subject metrics" plot (two
|
||||
Recharts `LineChart`s — count-per-session and success-rate — where each line is one
|
||||
subject, colored by group):
|
||||
|
||||
1. A toggleable sidebar listing subjects grouped by their group, with per-subject and
|
||||
per-group checkboxes to show/hide each subject's line.
|
||||
2. Clicking a subject's legend entry toggles that subject's line visibility.
|
||||
3. The hover tooltip orders its rows to match the vertical stacking of the points
|
||||
(highest value at top).
|
||||
4. Hovering a subject's legend entry (or sidebar row) highlights that subject's line.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Frontend only.** No backend, API, or database changes. All state is client-side
|
||||
React state, not persisted.
|
||||
- **No regressions.** The existing global controls (X axis, Color by, tick step), the
|
||||
per-chart customize bars (size, Y range, X zoom, per-chart X-field override), the
|
||||
Y-metric selector, and the sibling `SubjectAnalysisCharts` component are untouched.
|
||||
- Scope is limited to `ExperimentAnalysisCharts`.
|
||||
|
||||
## Visibility model (decided)
|
||||
|
||||
Visibility is **global per-subject**: toggling a subject anywhere — sidebar checkbox,
|
||||
either chart's legend — hides or shows it in **both** charts simultaneously. "Per plot"
|
||||
in the original request resolves to per-subject, not per-chart.
|
||||
|
||||
## Shared state (lifted into `ExperimentAnalysisCharts`)
|
||||
|
||||
- `hiddenSubjects: Set<animalId>` — empty = all visible. Global across both charts.
|
||||
Stale ids (subject removed) are harmless: they match no line.
|
||||
- `highlightedSubject: animalId | null` — transient hover state, shared by both charts
|
||||
and the sidebar.
|
||||
- `sidebarOpen: boolean` — default `false`.
|
||||
|
||||
Helpers: `toggleHidden(id)`, `setGroupHidden(groupSubjectIds, hidden)`, `showAll()`,
|
||||
`setHighlight(id | null)`.
|
||||
|
||||
## Layout
|
||||
|
||||
A toggle button (`▸ Subjects`) is added to the existing global-controls row. When open,
|
||||
the card body becomes a flex row:
|
||||
|
||||
- Charts area: `flex-1`; the existing `ResponsiveContainer width="100%"` re-flows the
|
||||
charts narrower automatically.
|
||||
- Sidebar panel: ~210px fixed width, `border-l`, top-aligned with the card.
|
||||
|
||||
```
|
||||
┌─ Cross-subject metrics ───────── X axis▾ Color by▾ Tick [▸ Subjects] ─┐
|
||||
│ │ SUBJECTS Show all │
|
||||
│ ┌──────────────────────────────┐ │ ┌───────────────────────┐ │
|
||||
│ │ Count chart (re-flows) │ │ │ ▣ Group A (3)│ │
|
||||
│ └──────────────────────────────┘ │ │ ☑ ● Mouse-A1 │ │
|
||||
│ ┌──────────────────────────────┐ │ │ ☑ ● Mouse-A2 │ │
|
||||
│ │ Success-rate chart │ │ │ ☐ ● Mouse-A3 │ │
|
||||
│ └──────────────────────────────┘ │ │ ▢ Group B (2)│ │
|
||||
│ │ │ ☐ ● Mouse-B1 │ │
|
||||
│ │ └───────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Sidebar contents
|
||||
|
||||
- **Grouping** reuses the current `Color by` field via the existing `lines[].group`.
|
||||
With `Color by = none`, all subjects fall under a single "—" group.
|
||||
- **Subject row**: checkbox (checked = visible) + color swatch (`lines[].color`) + name.
|
||||
Hovering the row sets `highlightedSubject`.
|
||||
- **Group row**: tri-state checkbox — checked if all in group visible, empty if all
|
||||
hidden, indeterminate if mixed. Clicking toggles the whole group. Shows group name +
|
||||
count.
|
||||
- **"Show all"** header link — clears `hiddenSubjects`; shown only when something is
|
||||
hidden.
|
||||
|
||||
## Behavior details
|
||||
|
||||
1. **Visibility** — every subject `<Line>` is rendered always (in both charts) with
|
||||
`hide={hiddenSubjects.has(l.id)}` instead of filtering the `lines` array. Recharts
|
||||
keeps a greyed, still-clickable legend entry for hidden lines and excludes them from
|
||||
the tooltip payload automatically.
|
||||
|
||||
2. **Legend click** — `<Legend onClick={(o) => toggleHidden(o.dataKey)} />` on both
|
||||
charts. `dataKey` is `l.id` (lines use `dataKey={l.id} name={l.id}`).
|
||||
|
||||
3. **Tooltip ordering** — the existing custom `ExpTooltip` sorts `payload` by `value`
|
||||
descending before rendering its rows, so the topmost line is first. Null/hidden
|
||||
subjects are already excluded. Applies to both the count and rate tooltips.
|
||||
|
||||
4. **Legend hover highlight** — `<Legend onMouseEnter={(o) => setHighlight(o.dataKey)}
|
||||
onMouseLeave={() => setHighlight(null)} />`. Each line's `strokeWidth` is `3.5` when
|
||||
`highlightedSubject === l.id`, else `2` (other lines unchanged — "thicken hovered
|
||||
only"). Highlight applies in **both** charts and is also driven by sidebar-row hover.
|
||||
|
||||
## Testing
|
||||
|
||||
- Manual verification in the running app (`ExperimentDetail` page with an experiment
|
||||
that has animals and saved analysis metrics):
|
||||
- Open/close sidebar; charts re-flow.
|
||||
- Toggle a subject checkbox → line disappears in both charts; legend entry greys out.
|
||||
- Group checkbox toggles all members; shows indeterminate when mixed.
|
||||
- "Show all" clears hidden state.
|
||||
- Click a legend entry → toggles in both charts.
|
||||
- Hover tooltip rows are ordered highest-value-first.
|
||||
- Hover a legend entry / sidebar row → that line thickens in both charts.
|
||||
- Confirm existing controls (X axis, Color by, tick step, per-chart customize, Y-metric)
|
||||
still work.
|
||||
```
|
||||
@@ -0,0 +1,137 @@
|
||||
# Persist cross-subject plot display settings per experiment
|
||||
|
||||
**Date:** 2026-06-01
|
||||
**Backend:** `backend/src/routes/experiments.js`, Prisma schema + migration
|
||||
**Frontend:** `frontend/src/components/AnalysisCharts.jsx` (`ExperimentAnalysisCharts`), `frontend/src/pages/ExperimentDetail.jsx`, `frontend/src/api/client.js`, `frontend/src/lib/crossSubjectChart.js`
|
||||
|
||||
## Summary
|
||||
|
||||
Persist a user's display settings for the "Cross-subject metrics" plot per experiment, so they are restored on the next visit instead of being re-applied every time. The settings live in a new nullable JSONB column on `experiments`, are saved via a dedicated auto-saving (debounced) endpoint, and hydrate the chart on load.
|
||||
|
||||
## Decisions (from brainstorming)
|
||||
|
||||
- **Scope:** the cross-subject plot only (the per-experiment chart in `ExperimentAnalysisCharts`). The sibling `SubjectAnalysisCharts` (per-animal page) is out of scope.
|
||||
- **Save trigger:** auto-save, debounced ~1s after the last change. No explicit save button.
|
||||
- **Storage:** Postgres via Prisma (the project's DB; it is not SQLite), following the existing `template` / `subject_info_template` / `csv_analysis_config` JSONB pattern.
|
||||
|
||||
## Persisted config shape
|
||||
|
||||
`experiments.plot_config` (JSONB, nullable). `null` = nothing saved → chart uses computed defaults.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"xField": "<fieldId|'__days__'>",
|
||||
"groupBy": "<fieldId|''>",
|
||||
"tickStep": 1,
|
||||
"metric": "total|Success|Failure",
|
||||
"hiddenSubjects": ["<animalId>", ...],
|
||||
"sidebarOpen": false,
|
||||
"counts": { "height": 200, "yMin": "", "yMax": "", "xZoomMin": "", "xZoomMax": "", "xFieldOverride": null },
|
||||
"rate": { "height": 160, "yMin": "", "yMax": "", "xZoomMin": "", "xZoomMax": "", "xFieldOverride": null }
|
||||
}
|
||||
```
|
||||
|
||||
All keys are optional on read — unknown/missing keys fall back to defaults, so older blobs and future additions both stay safe. `highlightedSubject` is transient and is NOT persisted.
|
||||
|
||||
## Backend
|
||||
|
||||
### Migration (additive, safe)
|
||||
|
||||
`backend/prisma/migrations/<ts>_add_experiment_plot_config/migration.sql`:
|
||||
```sql
|
||||
ALTER TABLE "experiments" ADD COLUMN "plot_config" JSONB;
|
||||
```
|
||||
Schema: add `plot_config Json?` to `model Experiment`. Existing rows get `NULL`; nothing else changes.
|
||||
|
||||
### Route: `PUT /api/experiments/:id/plot-config`
|
||||
|
||||
Mirrors the existing `PUT /:id/subject-template` handler, with two differences: **no audit-log write** (debounced auto-save would spam `audit_logs`; this is a UI preference, not experiment data), and a different validator.
|
||||
|
||||
```js
|
||||
// PUT /api/experiments/:id/plot-config — persist UI display settings (no audit log)
|
||||
router.put('/:id/plot-config', 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 config = req.body;
|
||||
const validationError = validatePlotConfig(config);
|
||||
if (validationError) return res.status(422).json({ error: validationError });
|
||||
|
||||
await prisma.experiment.update({
|
||||
where: { id: req.params.id },
|
||||
data: { plot_config: config },
|
||||
});
|
||||
res.json(config);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
```
|
||||
|
||||
`validatePlotConfig(config)`:
|
||||
- Must be a non-null, non-array plain object → else `'plot_config must be an object'`.
|
||||
- Serialized size cap: `JSON.stringify(config).length <= 16384` → else `'plot_config too large'`.
|
||||
- If `hiddenSubjects` present, must be an array of strings with length ≤ 500 → else `'hiddenSubjects invalid'`.
|
||||
- Otherwise permissive (stores the object as-is). Keeps the backend agnostic to the exact UI shape.
|
||||
|
||||
### Read path
|
||||
|
||||
`GET /api/experiments/:id` already returns all scalar columns (it uses `findUnique` without a `select`), so `plot_config` rides along automatically. No new GET endpoint.
|
||||
|
||||
## Frontend
|
||||
|
||||
### API client (`frontend/src/api/client.js`)
|
||||
|
||||
Add to `experimentsApi`:
|
||||
```js
|
||||
savePlotConfig: (id, config) => api.put(`/experiments/${id}/plot-config`, config).then((r) => r.data),
|
||||
```
|
||||
|
||||
### Pure helpers (`frontend/src/lib/crossSubjectChart.js`)
|
||||
|
||||
Two pure, unit-tested functions isolate (de)serialization from React:
|
||||
|
||||
- `serializePlotConfig({ xField, groupBy, tickStep, metric, hiddenSubjects, sidebarOpen, counts, rate })` → plain JSON object (turns the `hiddenSubjects` Set into a sorted array; copies the two per-chart snapshots).
|
||||
- `readPlotConfig(raw)` → normalized `{ xField?, groupBy?, tickStep?, metric?, hiddenSubjects: string[], sidebarOpen: bool, counts: {...}, rate: {...} }` with defensive defaults for `null`/malformed input (never throws).
|
||||
|
||||
### `useChartSettings` refactor (`AnalysisCharts.jsx`)
|
||||
|
||||
- Add optional `initial` argument; seed `height`, `yMin`, `yMax`, `xZoomMin`, `xZoomMax`, `xFieldOverride` from it when provided.
|
||||
- Expose a `snapshot` object (those serializable values) for the parent to persist.
|
||||
- `reset()` and `isDirty` keep working. `SubjectAnalysisCharts` calls the hook with no `initial`, so its behavior is unchanged.
|
||||
|
||||
### `ExperimentAnalysisCharts` wiring
|
||||
|
||||
New props: `plotConfig` (raw from `experiment.plot_config`) and `onPersistConfig(config)`.
|
||||
|
||||
- **Hydrate once:** read `readPlotConfig(plotConfig)` into a memo; pass `counts`/`rate` slices as `initial` to the two `useChartSettings` calls; in a one-time effect (guarded by `hydratedRef`) seed `xField`, `groupBy`, `tickStep`, `metric`, `hiddenSubjects` (`new Set(cfg.hiddenSubjects)`), `sidebarOpen`.
|
||||
- **Serialize:** `const currentConfig = useMemo(() => serializePlotConfig({...all state, counts: countsS.snapshot, rate: rateS.snapshot}), [deps])`.
|
||||
- **Debounced save:** an effect keyed on `JSON.stringify(currentConfig)` that, *after* hydration and only when the value differs from `lastSavedRef.current`, sets a 1s timeout calling `onPersistConfig(currentConfig)`, then updates `lastSavedRef`. Clears the timeout on change/unmount. This prevents both a save-on-load loop and no-op writes.
|
||||
- **Status hint:** a tiny "Saving…/Saved ✓" indicator near the controls; on save failure show a quiet "couldn't save layout" note and leave the chart fully functional.
|
||||
|
||||
### `ExperimentDetail.jsx`
|
||||
|
||||
Pass the two props:
|
||||
```jsx
|
||||
<ExperimentAnalysisCharts
|
||||
animals={animals}
|
||||
experimentStatuses={experimentStatuses}
|
||||
subjectTemplate={subjectTemplate}
|
||||
dailyTemplate={dailyTemplate}
|
||||
plotConfig={experiment.plot_config}
|
||||
onPersistConfig={(cfg) => experimentsApi.savePlotConfig(id, cfg)}
|
||||
/>
|
||||
```
|
||||
|
||||
## Edge cases & non-goals
|
||||
|
||||
- **Stale subject ids:** a hidden `animalId` for a since-removed animal simply matches no line (already how visibility behaves). No cleanup.
|
||||
- **Single-tenant, last-write-wins:** no per-user configs (there is no auth layer); config is shared per experiment.
|
||||
- **Failure is non-fatal:** a failed save never breaks the chart; the user just sees the quiet note and can retry by changing something.
|
||||
- **Untouched:** template/subject-template routes and their audit logging; `SubjectAnalysisCharts`; the cross-subject interaction behaviors shipped previously.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Pure helpers (Jest, `frontend/tests/crossSubjectChart.test.js`):** `serializePlotConfig` round-trips (Set→sorted array, includes per-chart snapshots); `readPlotConfig` returns defaults for `null`/`{}`/malformed input and preserves valid fields. Never throws.
|
||||
- **Backend route (Jest + supertest + mocked Prisma, `backend/tests/experiments.test.js`):** `PUT /:id/plot-config` returns 200 and calls `prisma.experiment.update` with the config for a valid body; 404 when the experiment is missing; 422 for a non-object body, an oversized body, and an invalid `hiddenSubjects`; and does NOT call `prisma.auditLog.create`.
|
||||
- **Frontend smoke (Jest + RTL, mocked recharts, `frontend/tests/ExperimentAnalysisCharts.test.jsx`):** hydrates state from a `plotConfig` prop (e.g. a hidden subject starts unchecked); and, with fake timers, calls `onPersistConfig` ~1s after a change but not on initial render.
|
||||
- **Manual:** adjust the plot on one experiment, reload → settings persist; open a second experiment → independent config; confirm no new `audit_logs` rows from plot tweaks.
|
||||
@@ -0,0 +1,148 @@
|
||||
# Experiment Data Export (CSV) — Design
|
||||
|
||||
**Date:** 2026-07-06
|
||||
**Status:** Approved, ready for implementation plan
|
||||
|
||||
## Goal
|
||||
|
||||
Let a user on the experiment page export a chosen daily parameter as a CSV
|
||||
file shaped as a matrix: **each row is a date, each column is a subject**, each
|
||||
cell is that subject's value for the parameter on that date. Every daily
|
||||
parameter must be exportable, including the session metrics derived from
|
||||
`analysis_summary`.
|
||||
|
||||
## Scope
|
||||
|
||||
- **In:** CSV export, one parameter per file, client-side generation, a picker
|
||||
UI on the experiment page.
|
||||
- **Out (deferred):** true `.xlsx` output (would add a client-side library such
|
||||
as SheetJS later; the pure helpers stay format-agnostic so the workbook
|
||||
builder can be added without reshaping the data), multi-parameter / multi-sheet
|
||||
export, a backend export endpoint.
|
||||
|
||||
## Approach
|
||||
|
||||
Fully client-side. The existing `GET /api/experiments/:id/daily-statuses`
|
||||
endpoint already returns every field required per status — builtin fields
|
||||
(`experiment_description`, `vitals`, `treatment`, `notes`), `custom_fields`, and
|
||||
`analysis_summary`. The experiment page currently fetches only the
|
||||
`analysisOnly=true` subset (for charts); the export modal fetches the **full**
|
||||
set on open, pivots it into a date×subject matrix in the browser, serializes to
|
||||
CSV, and triggers a download. No backend change.
|
||||
|
||||
Rejected alternative: a backend `/export` route streaming CSV. It only pays off
|
||||
for very large datasets or server-side xlsx, at the cost of a new route, tests,
|
||||
and duplicating the template/parameter logic on the server. Research datasets
|
||||
here are small (tens of subjects, hundreds of dates), so client-side is simpler
|
||||
and unit-testable.
|
||||
|
||||
## Exportable parameters
|
||||
|
||||
The picker is grouped into two sections.
|
||||
|
||||
### Session metrics (from `analysis_summary`)
|
||||
|
||||
- **Total attempts** → `analysis_summary.total`
|
||||
- **Success rate** → `analysis_summary.success_rate`, exported as the **raw
|
||||
0–1 fraction** (not multiplied to a percentage)
|
||||
- One entry per **dynamic count category** found in the data →
|
||||
`analysis_summary.counts[category]`. Categories are enumerated by scanning the
|
||||
fetched statuses (e.g. `Success`, `Failure`, `Other`, and any others present).
|
||||
|
||||
### Daily record fields (from the daily template)
|
||||
|
||||
- **Every** field defined in the daily template, including ones currently
|
||||
hidden/inactive, since historical data may exist for them.
|
||||
- Builtin fields read from `status[field.key]`; custom fields read from
|
||||
`status.custom_fields[field.fieldId]` (same accessor logic as
|
||||
`AnalysisCharts.numericFieldVal`).
|
||||
- Values export as-is: text fields (e.g. `notes`, `vitals`) as strings, numeric
|
||||
fields as numbers. No numeric coercion is required for export.
|
||||
|
||||
## Matrix shape
|
||||
|
||||
- **Rows** = every date that has at least one value for the chosen parameter
|
||||
across any subject, sorted ascending, formatted `YYYY-MM-DD`. The first column
|
||||
header is `Date`.
|
||||
- **Columns** = all enrolled animals, ordered by `animal_name`. A subject is a
|
||||
column even if it has no value for the parameter (blank cells). On duplicate
|
||||
`animal_name`, disambiguate the header with the subject's `animal_id_string`
|
||||
(e.g. `Rat A (R-001)`).
|
||||
- **Cell** = the subject's value for the parameter on that date. Assumes one
|
||||
daily status per (subject, date); if duplicates are ever present, the first in
|
||||
date-ascending order wins.
|
||||
- Empty result (no dates have any value) is surfaced in the UI as a disabled
|
||||
export / "no data for this parameter" note rather than producing an empty file.
|
||||
|
||||
## Components
|
||||
|
||||
### `frontend/src/lib/dataExport.js` (new, pure — no React, no I/O)
|
||||
|
||||
Mirrors the existing `frontend/src/lib/crossSubjectChart.js` pure-helper pattern.
|
||||
|
||||
- `listExportableParams(dailyTemplate, statuses)` → grouped, ordered parameter
|
||||
descriptors. Each descriptor carries an id, label, group (`'metrics'` |
|
||||
`'daily'`), and enough info to extract a value from a status. Session-metric
|
||||
categories are derived from `statuses`; template params from `dailyTemplate`.
|
||||
- `extractValue(status, param)` → the raw cell value for one status/param (or
|
||||
`null`/blank when absent). Encapsulates the builtin-vs-custom and
|
||||
`analysis_summary` accessor logic.
|
||||
- `buildMatrix(statuses, animals, param)` → `{ columns, rows }` where `columns`
|
||||
are the ordered subject headers and `rows` are `{ date, values: [...] }`
|
||||
aligned to `columns`, including blank cells; rows limited to dates with data.
|
||||
- `toCSV(matrix)` → CSV string. Escapes any field containing a comma, double
|
||||
quote, or newline per RFC 4180 (wrap in quotes, double embedded quotes).
|
||||
|
||||
### `frontend/src/components/ExportDataModal.jsx` (new)
|
||||
|
||||
- On open, fetches the full daily-statuses set via
|
||||
`experimentsApi.getDailyStatuses(id, {})` (no `analysisOnly`).
|
||||
- Renders a grouped `<select>` of exportable parameters (Session metrics /
|
||||
Daily record fields).
|
||||
- Shows a small summary line: "N dates × M subjects".
|
||||
- **Export CSV** button builds a `Blob`, creates an object URL, clicks a
|
||||
temporary anchor, and revokes the URL. Filename:
|
||||
`<sanitized-experiment-title>-<sanitized-param-label>.csv`.
|
||||
- Handles loading and error states for the fetch.
|
||||
|
||||
### `frontend/src/pages/ExperimentDetail.jsx` (edit)
|
||||
|
||||
- Add an **Export data** button in the header area (near **+ Add Animal**) that
|
||||
opens `ExportDataModal`, passing the experiment id, title, `dailyTemplate`,
|
||||
and `animals`.
|
||||
|
||||
## Data flow
|
||||
|
||||
1. User clicks **Export data** → modal opens.
|
||||
2. Modal fetches full statuses (all fields, all subjects).
|
||||
3. `listExportableParams(dailyTemplate, statuses)` builds the picker options.
|
||||
4. User selects a parameter → `buildMatrix(statuses, animals, param)` computes
|
||||
the date×subject matrix; summary line updates.
|
||||
5. User clicks **Export CSV** → `toCSV(matrix)` → Blob download.
|
||||
|
||||
## Error handling
|
||||
|
||||
- Fetch failure: show an inline error in the modal; no download.
|
||||
- Parameter with no data across all subjects/dates: disable export and show a
|
||||
brief note.
|
||||
- CSV correctness relies on `toCSV` escaping; covered by tests.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit tests for `dataExport.js`, following `frontend/tests/crossSubjectChart.test.js`:
|
||||
|
||||
- `listExportableParams`: includes all template fields (incl. inactive), the two
|
||||
fixed session metrics, and each dynamic count category discovered in the data;
|
||||
correct grouping/ordering.
|
||||
- `extractValue`: builtin vs custom field access; `total`, `success_rate` (raw
|
||||
0–1), and `counts[category]`; missing values → blank/null.
|
||||
- `buildMatrix`: rows only for dates with data, ascending; all subjects as
|
||||
columns with blanks; duplicate-name disambiguation; one-status-per-cell.
|
||||
- `toCSV`: escaping of commas, quotes, and newlines; header row; blank cells.
|
||||
|
||||
## Out of scope / future
|
||||
|
||||
- `.xlsx` output via a client-side workbook library (helpers already
|
||||
format-agnostic).
|
||||
- Selecting multiple parameters (multi-sheet xlsx or multi-file/zip CSV).
|
||||
- Server-side export for very large datasets.
|
||||
@@ -0,0 +1,101 @@
|
||||
# Configurable Data Export — Design
|
||||
|
||||
**Date:** 2026-07-19
|
||||
**Status:** Approved (design)
|
||||
**Area:** `frontend/src/lib/dataExport.js`, `frontend/src/components/ExportDataModal.jsx`, `frontend/tests/dataExport.test.js`
|
||||
|
||||
## Problem
|
||||
|
||||
Today's data export has a fixed layout: each **row is a date**, each **column is a subject**, and the user picks **one parameter** whose value fills every cell (`buildMatrix` in `dataExport.js`). Users want to:
|
||||
|
||||
1. Choose which dimension goes on rows and which on columns (fully assignable axes).
|
||||
2. See the pinned/third dimension labeled at the top of the file ("group information at the beginning").
|
||||
3. When Subject is an axis, see each subject's **group** in a separate cell, similar to the cross-subject metrics view.
|
||||
|
||||
## Model: three interchangeable dimensions
|
||||
|
||||
Export data spans three dimensions: **Date**, **Subject**, **Parameter**. A 2-D CSV grid can place two of them on the axes; the third must be **pinned** to a single value (a cell holds exactly one value).
|
||||
|
||||
Each dimension yields an ordered list of **members**:
|
||||
|
||||
- **Date** → sorted unique `YYYY-MM-DD` keys present in the data.
|
||||
- **Subject** → animals, headers disambiguated by `animal_id_string` on duplicate names (existing logic). When a group field is chosen, subjects are **clustered by group value, then sorted by name** within each group.
|
||||
- **Parameter** → `listExportableParams(dailyTemplate, statuses)` (session metrics, then daily fields — unchanged).
|
||||
|
||||
Every cell is identified by a full `(dateKey, animalId, param)` triple: two coordinates come from the row/column members, the third from the pinned value. Lookup goes through a status index `Map(dateKey → Map(animalId → status))` (first-status-per-cell wins, as today), then `extractValue(status, param)`.
|
||||
|
||||
Because Subject is always exactly one of the three dimensions (row, column, or pinned), the group annotation attaches to **at most one axis** — there is no 2-D corner-block complexity.
|
||||
|
||||
## Modal UI (`ExportDataModal.jsx`)
|
||||
|
||||
Replaces today's single "Parameter" dropdown with four controls:
|
||||
|
||||
- **Rows** — Date / Subject / Parameter.
|
||||
- **Columns** — the two dimensions not chosen for Rows (auto-excludes the row pick; if a change collides, the other axis auto-shifts to a still-valid dimension).
|
||||
- **Fixed: `<leftover dim>`** — value dropdown for the auto-pinned third dimension. Its options are that dimension's members (a date, a subject, or a parameter, grouped like today for parameters).
|
||||
- **Group by** — subject_info fields plus "— None —". **Shown only when Subject is a row or column axis.** Defaults to the experiment's saved group field (`localStorage['exp-subject-group-<id>']`); falls back to None when unset or the field no longer exists.
|
||||
|
||||
Live preview line stays: `N rows × M columns` (or "No data …" when empty). **Default state reproduces today's export exactly**: Rows = Date, Columns = Subject, Fixed = Parameter (first parameter). Export button disabled when the grid has no data rows.
|
||||
|
||||
## CSV output format
|
||||
|
||||
Structure: pinned-dimension context line, blank line, optional group row, header row, data rows.
|
||||
|
||||
- **Line 1 (context):** two cells — the pinned dimension's label and its value, e.g. `Parameter,Total attempts` or `Subject,Mouse-01`. Beginning only (no footer).
|
||||
- **Blank line.**
|
||||
- **Grid**, with the group cell riding on whichever axis holds Subject.
|
||||
|
||||
**Subject = columns** (e.g. Rows = Date, Fixed = Parameter) → extra **header row** labeled `Group`:
|
||||
|
||||
```
|
||||
Parameter,Total attempts
|
||||
|
||||
Group,Control,Control,Drug,Drug
|
||||
Date,Mouse-01,Mouse-02,Mouse-03,Mouse-04
|
||||
2026-01-01,12,10,8,9
|
||||
```
|
||||
|
||||
**Subject = rows** (e.g. Cols = Parameter, Fixed = Date) → extra **leading column** labeled `Group`:
|
||||
|
||||
```
|
||||
Date,2026-01-01
|
||||
|
||||
Group,Subject,Total attempts,Success rate
|
||||
Control,Mouse-01,12,0.83
|
||||
Drug,Mouse-03,8,0.71
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- The grid **corner label** is the row dimension's name (`Date`, `Subject`, or `Parameter`) — no longer hardcoded `Date`.
|
||||
- Missing group value → `—`. When **Group by = None**, the group row/column is omitted entirely.
|
||||
- **Empty-member rule:** Subject members are **always kept** (matches today's "keep all subject columns"). Date and Parameter members are **dropped when entirely blank** in the current grid.
|
||||
- Cell values use existing semantics: `0` / `''` are real; genuinely-missing lookups render as blank. CSV escaping via existing `escapeCsvCell`.
|
||||
|
||||
## Refactor of `dataExport.js`
|
||||
|
||||
Keep `extractValue` and `listExportableParams` unchanged. Replace single-purpose `buildMatrix` with dimension-agnostic pieces:
|
||||
|
||||
- `listDateMembers(statuses)` → sorted unique dateKeys.
|
||||
- `listSubjectMembers(animals, groupField)` → `[{ id, header, group }]`, clustered by group then name; header disambiguation preserved.
|
||||
- `buildStatusIndex(statuses)` → `Map(dateKey → Map(animalId → status))`, first-wins.
|
||||
- `buildMatrix({ rowDim, colDim, pinnedDim, pinnedMember, groupField }, { statuses, animals, dailyTemplate })` → `{ context, groupAxis, corner, columns, rows }`. Resolves each cell's `(dateKey, animalId, param)` triple through the index + `extractValue`; applies the keep-subjects / drop-blank-date-and-param rule.
|
||||
- `toCSV(matrix)` → emits context line, blank line, optional group row, header row, data rows, with the dynamic corner label.
|
||||
- `csvFilename(title, pinnedMember.label)` → signature unchanged.
|
||||
|
||||
## Testing (`tests/dataExport.test.js`)
|
||||
|
||||
Existing `extractValue` / `listExportableParams` tests stay green. Add:
|
||||
|
||||
- `listDateMembers`, `listSubjectMembers` (including group clustering + name tie-break + header disambiguation), `buildStatusIndex` (first-wins).
|
||||
- `buildMatrix` for all three axis pairings in both orientations.
|
||||
- Group row (Subject = columns) vs. group column (Subject = rows) placement; `—` fallback; Group = None omits it.
|
||||
- Empty-member rule: blank date/parameter members dropped, subjects retained.
|
||||
- `toCSV` snapshot including the context line and a group row.
|
||||
|
||||
## Out of scope (YAGNI)
|
||||
|
||||
- Stacking multiple values of the pinned dimension into one file (chose "pick one value").
|
||||
- Footer/repeat of the context line (beginning only).
|
||||
- Experiment-level metadata block (pinned dimension only).
|
||||
- Persisting axis choices across sessions (default reproduces current behavior; group field reuses existing saved value only).
|
||||
@@ -0,0 +1,86 @@
|
||||
# Subject-Series Data Export — Design
|
||||
|
||||
**Date:** 2026-07-19
|
||||
**Status:** Approved (design)
|
||||
**Area:** `frontend/src/lib/dataExport.js`, `frontend/src/components/ExportDataModal.jsx`, `frontend/tests/`
|
||||
**Supersedes:** the assignable-axes UI from `2026-07-19-configurable-data-export-design.md` (that branch's `buildMatrix`/`toCSV`/subject-group work is reused; its rows/columns/pinned modal is replaced).
|
||||
|
||||
## Problem
|
||||
|
||||
The export modal currently offers assignable row/column/pinned axes. That is more than needed. The user wants the export to mirror the **cross-subject metrics plot**: pick an **X field** and a **data value**, with each **subject as a column series**, empty where a subject has no data point, plus each subject's **group** included. The one capability missing today is choosing the X (row) field — it is fixed to calendar Date, whereas the plot's X-axis offers **# Days Reach** and daily fields.
|
||||
|
||||
## Layout (fixed)
|
||||
|
||||
- **Columns = subjects**, one series per subject, ordered clustered-by-group then by name (existing `listSubjectMembers`).
|
||||
- **Rows = the chosen X field's values.**
|
||||
- **Cells = the chosen Data value** for that (subject, X); empty (`''`) when absent. `0` and `''` remain real values (existing `hasValue`/`extractValue`).
|
||||
- **Group row** gives each subject's group value (existing group-row support).
|
||||
|
||||
There is no pinned dimension and no assignable columns.
|
||||
|
||||
## The three pickers
|
||||
|
||||
1. **X (rows).** Options: `Date`, `# Days Reach`, and each **active** daily-template field. Default `Date` (keeps the same date-rows × subject-columns grid as today; only the line-1 label changes to `Data`, see CSV output).
|
||||
2. **Data (cells).** Options: `listExportableParams(dailyTemplate, statuses)` — total, success rate, each count category, each daily field. Default: first parameter (`Total attempts`).
|
||||
3. **Group by.** Options: `— None —`, `Name`, `Subject ID`, and each active `subject_info` field. Defaults to the experiment's saved group field (`localStorage['exp-subject-group-<id>']`), validated against the available options and coerced to `__none__` when unset or stale. Always visible (subjects are always the columns).
|
||||
|
||||
Preview line: `N rows × M subjects`. Export disabled when there are no data rows.
|
||||
|
||||
## X-field semantics
|
||||
|
||||
Each X coordinate maps a `(subject, X-value)` pair to a single daily status, from which the Data value is read:
|
||||
|
||||
- **`Date`** → rows are the distinct calendar dates present (`YYYY-MM-DD`), sorted. Cell reads the subject's status on that date. (Today's behavior.)
|
||||
- **`# Days Reach`** → rows are `1, 2, 3, …` up to the maximum, per subject, of the count of that subject's recorded daily statuses. A cell for `(subject, N)` reads the subject's **Nth recorded day**, counting **all** recorded statuses sorted ascending by date (Day 1 = earliest record; days without an analysis summary are still counted — this intentionally differs from the plot, which counts analyzed sessions only).
|
||||
- **daily field** (e.g. `Weight (g)`) → rows are the distinct **numeric** values that field takes across the data (`numericFieldVal`: builtin via `status[field.key]`, custom via `status.custom_fields[field.fieldId]`, `parseFloat`, non-numeric → excluded), sorted ascending. A cell for `(subject, v)` reads that subject's status where the field equals `v`; on ties, first status by date wins.
|
||||
|
||||
Row members that are entirely blank across all subjects are dropped (matching Date's existing behavior); subject columns are always kept even when empty.
|
||||
|
||||
## CSV output
|
||||
|
||||
Structure (X = # Days Reach, Data = Success rate, Group by = Treatment):
|
||||
|
||||
```
|
||||
Data,Success rate
|
||||
Group,Control,Control,Drug
|
||||
# Days Reach,Mouse-A,Mouse-B,Mouse-C
|
||||
1,0.5,0.6,0.7
|
||||
2,0.55,,0.72
|
||||
3,0.6,0.65,
|
||||
```
|
||||
|
||||
- **Line 1** names the Data metric so the file is self-describing: two cells, `Data` and the metric label.
|
||||
- **Blank line.**
|
||||
- **Group row** (`Group` + each subject's group), only when Group by ≠ None; missing group value → `—`.
|
||||
- **Header row:** the X-field label in the corner, then subject headers.
|
||||
- **Data rows:** the X value, then one cell per subject.
|
||||
|
||||
LF line endings; every cell escaped via existing `escapeCsvCell`. Filename: existing `csvFilename(title, dataLabel)`.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
Reuse from the current branch: `extractValue`, `listExportableParams`, `subjectGroupValue`, `listSubjectMembers`, `escapeCsvCell`, `toCSV` (its `{ context, corner, groupAxis, columns, rows }` shape is kept), and `csvFilename`.
|
||||
|
||||
Replace: the general assignable-axes `buildMatrix(config, ctx)` and its members/dispatch helpers (`otherDimension`, `dimLabel`, `EXPORT_DIMENSIONS`, `listDateMembers`, `listDimensionMembers`, `listParameterMembers`, `buildStatusIndex` in its current form) — remove what the fixed layout no longer uses rather than leaving dead code.
|
||||
|
||||
Add:
|
||||
- `numericFieldVal(status, field)` — mirror of the plot's helper (or import/share it).
|
||||
- `listSampleMembers(statuses, xField, dailyTemplate)` → the ordered row members for the chosen X coordinate (`{ id, label, ... }`).
|
||||
- `buildSampleIndex(statuses, xField, dailyTemplate)` → `Map(animalId → Map(sampleValue → status))`, first-status-per-cell wins, encoding the date/#days/field resolution above.
|
||||
- `buildSubjectSeriesMatrix({ xField, dataParam, groupField }, { statuses, animals, dailyTemplate })` → `{ context: { label: 'Data', value: dataParam.label }, corner: <X label>, groupAxis: grouping ? 'col' : null, columns: <subjects>, rows: [{ member, values }] }`, consumable by the existing `toCSV`.
|
||||
|
||||
Simplify `ExportDataModal.jsx` to the three pickers over these helpers; drop the Rows/Columns/Fixed controls.
|
||||
|
||||
## Testing
|
||||
|
||||
- `listSampleMembers` / `buildSampleIndex` for each X coordinate: Date (unchanged), # Days Reach (all-records ordinal, per-subject alignment), daily field (distinct numeric values sorted, non-numeric dropped, tie first-by-date).
|
||||
- `buildSubjectSeriesMatrix`: subjects as columns clustered by group; empty cells where a subject lacks a point; blank row dropped; `groupAxis` null when Group by None.
|
||||
- `toCSV` reused; add/keep a snapshot for the new context line + group row.
|
||||
- `ExportDataModal`: renders three pickers; default X = Date produces date rows × subject columns with the chosen metric in cells; switching X to # Days Reach changes rows to ordinals; group row present when a valid group field is chosen; stale/unset group field coerces to None.
|
||||
|
||||
## Out of scope (YAGNI)
|
||||
|
||||
- Assignable columns / pinned dimension (removed).
|
||||
- Choosing a subset of subjects (all subjects always included).
|
||||
- Matching the plot's analyzed-only day counting (we count all recorded days).
|
||||
- A footer or repeated context line.
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
['@babel/preset-env', { targets: { node: 'current' } }],
|
||||
['@babel/preset-react', { runtime: 'automatic' }],
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
// @ts-check
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
/**
|
||||
* E2E tests run against the full Docker stack (frontend on port 52867,
|
||||
* backend on port 3001). To run locally:
|
||||
* docker-compose up -d
|
||||
* npx playwright test
|
||||
*/
|
||||
|
||||
const BASE_URL = process.env.E2E_BASE_URL || 'http://localhost:52867';
|
||||
|
||||
test.describe('UI Flow — create experiment → add animal → log status', () => {
|
||||
test('full happy path', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
|
||||
// ── Create experiment ────────────────────────────────────────
|
||||
await page.click('button:has-text("New Experiment")');
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await page.getByLabel(/experiment title/i).fill('E2E Test Study');
|
||||
await page.click('button:has-text("Create Experiment")');
|
||||
|
||||
// Modal closes and new experiment appears in the list
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||
await expect(page.getByText('E2E Test Study')).toBeVisible();
|
||||
|
||||
// ── Navigate into experiment ──────────────────────────────────
|
||||
await page.click('text=E2E Test Study');
|
||||
await expect(page.url()).toContain('/experiments/');
|
||||
|
||||
// ── Add animal ────────────────────────────────────────────────
|
||||
await page.click('button:has-text("Add Animal")');
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await page.getByLabel(/animal id/i).fill('M-E2E-001');
|
||||
await page.getByLabel(/animal name/i).fill('TestMouse');
|
||||
await page.click('button:has-text("Add Animal")');
|
||||
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||
await expect(page.getByText('TestMouse')).toBeVisible();
|
||||
|
||||
// ── Navigate into animal ──────────────────────────────────────
|
||||
await page.click('text=TestMouse');
|
||||
await expect(page.url()).toContain('/animals/');
|
||||
|
||||
// ── Log daily status ──────────────────────────────────────────
|
||||
await page.click('button:has-text("Log Daily Status")');
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Date is pre-filled with today; add vitals
|
||||
await page.getByLabel(/vitals/i).fill('HR 70, Temp 37.0');
|
||||
await page.getByLabel(/treatment/i).fill('Control');
|
||||
await page.click('button:has-text("Log Status")');
|
||||
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||
await expect(page.getByText('HR 70, Temp 37.0')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('UX Flow — validation error states', () => {
|
||||
test('empty experiment title shows error', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await page.click('button:has-text("New Experiment")');
|
||||
await page.click('button:has-text("Create Experiment")');
|
||||
await expect(page.getByRole('alert')).toContainText(/title is required/i);
|
||||
});
|
||||
|
||||
test('empty animal fields show errors', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
|
||||
// Need an experiment to navigate into — create one first
|
||||
await page.click('button:has-text("New Experiment")');
|
||||
await page.getByLabel(/experiment title/i).fill('UX Test Study');
|
||||
await page.click('button:has-text("Create Experiment")');
|
||||
await page.click('text=UX Test Study');
|
||||
|
||||
await page.click('button:has-text("Add Animal")');
|
||||
await page.click('button:has-text("Add Animal")'); // submit without filling
|
||||
const alerts = page.getByRole('alert');
|
||||
await expect(alerts).toHaveCount(2); // ID + Name errors
|
||||
});
|
||||
|
||||
test('missing date on daily status shows error', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
|
||||
// Create experiment + animal to get to daily status form
|
||||
await page.click('button:has-text("New Experiment")');
|
||||
await page.getByLabel(/experiment title/i).fill('Date Test Study');
|
||||
await page.click('button:has-text("Create Experiment")');
|
||||
await page.click('text=Date Test Study');
|
||||
|
||||
await page.click('button:has-text("Add Animal")');
|
||||
await page.getByLabel(/animal id/i).fill('M-DT-001');
|
||||
await page.getByLabel(/animal name/i).fill('DateTestMouse');
|
||||
await page.click('button:has-text("Add Animal")');
|
||||
await page.click('text=DateTestMouse');
|
||||
|
||||
await page.click('button:has-text("Log Daily Status")');
|
||||
// Clear the date field
|
||||
await page.getByLabel(/date/i).fill('');
|
||||
await page.click('button:has-text("Log Status")');
|
||||
await expect(page.getByRole('alert')).toContainText(/date is required/i);
|
||||
});
|
||||
});
|
||||
Generated
+9136
-3582
File diff suppressed because it is too large
Load Diff
+28
-11
@@ -6,9 +6,8 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test": "node --max-old-space-size=8192 node_modules/.bin/jest --runInBand --forceExit",
|
||||
"test:coverage": "node --max-old-space-size=8192 node_modules/.bin/jest --runInBand --forceExit --coverage",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -16,22 +15,40 @@
|
||||
"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.15.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.24.7",
|
||||
"@babel/preset-env": "^7.24.7",
|
||||
"@babel/preset-react": "^7.24.6",
|
||||
"@playwright/test": "^1.44.1",
|
||||
"@testing-library/jest-dom": "^6.4.6",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@testing-library/react": "^14.3.1",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"@vitest/coverage-v8": "^1.6.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"jsdom": "^24.1.0",
|
||||
"babel-jest": "^29.7.0",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"postcss": "^8.4.39",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"vite": "^4.5.3",
|
||||
"vitest": "^1.6.0"
|
||||
"vite": "^4.5.3"
|
||||
},
|
||||
"jest": {
|
||||
"testEnvironment": "jsdom",
|
||||
"setupFilesAfterEnv": [
|
||||
"<rootDir>/tests/setup.js"
|
||||
],
|
||||
"testMatch": [
|
||||
"**/tests/**/*.test.{js,jsx}"
|
||||
],
|
||||
"transform": {
|
||||
"^.+\\.[jt]sx?$": "babel-jest"
|
||||
},
|
||||
"moduleNameMapper": {
|
||||
"\\.(css|less|scss|sass)$": "identity-obj-proxy"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
const { defineConfig, devices } = require('@playwright/test');
|
||||
|
||||
module.exports = defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 30000,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
use: {
|
||||
baseURL: process.env.E2E_BASE_URL || 'http://localhost:52867',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
],
|
||||
});
|
||||
+23
-3
@@ -1,8 +1,11 @@
|
||||
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';
|
||||
import ExperimentDayView from './pages/ExperimentDayView';
|
||||
|
||||
function Navbar() {
|
||||
return (
|
||||
@@ -33,18 +36,35 @@ function NotFound() {
|
||||
);
|
||||
}
|
||||
|
||||
function Footer() {
|
||||
const [dbTimezone, setDbTimezone] = useState(null);
|
||||
useEffect(() => {
|
||||
systemApi.info().then((d) => setDbTimezone(d.dbTimezone)).catch(() => {});
|
||||
}, []);
|
||||
return (
|
||||
<footer className="border-t border-gray-200 mt-12 py-3">
|
||||
<div className="max-w-5xl mx-auto px-4 text-xs text-gray-400 text-right">
|
||||
Database timezone: <span className="font-mono">{dbTimezone ?? '…'}</span>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col">
|
||||
<Navbar />
|
||||
<main>
|
||||
<main className="flex-1">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/experiments/:id" element={<ExperimentDetail />} />
|
||||
<Route path="/experiments/:id/day/:date" element={<ExperimentDayView />} />
|
||||
<Route path="/animals/:id" element={<AnimalDetail />} />
|
||||
<Route path="/daily-statuses/:id" element={<DailyStatusDetail />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,18 @@ export const experimentsApi = {
|
||||
create: (data) => api.post('/experiments', data).then((r) => r.data),
|
||||
update: (id, data) => api.put(`/experiments/${id}`, data).then((r) => r.data),
|
||||
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),
|
||||
savePlotConfig: (id, config) => api.put(`/experiments/${id}/plot-config`, config).then((r) => r.data),
|
||||
// opts: { date?: string, analysisOnly?: boolean }
|
||||
getDailyStatuses: (id, opts) => {
|
||||
const params = typeof opts === 'string' ? { date: opts } : (opts ?? {});
|
||||
return api.get(`/experiments/${id}/daily-statuses`, { params }).then((r) => r.data);
|
||||
},
|
||||
getDayStatuses: (id, date) => api.get(`/experiments/${id}/daily-statuses`, { params: { date } }).then((r) => r.data),
|
||||
getCalendar: (id, field) => api.get(`/experiments/${id}/calendar`, { params: { field } }).then((r) => r.data),
|
||||
};
|
||||
|
||||
// ── Animals ───────────────────────────────────────────────────────────────────
|
||||
@@ -39,6 +51,8 @@ export const animalsApi = {
|
||||
create: (data) => api.post('/animals', data).then((r) => r.data),
|
||||
update: (id, data) => api.put(`/animals/${id}`, data).then((r) => r.data),
|
||||
delete: (id) => api.delete(`/animals/${id}`),
|
||||
updateCsvConfig: (id, config) =>
|
||||
api.patch(`/animals/${id}/csv-config`, config).then((r) => r.data),
|
||||
};
|
||||
|
||||
// ── Daily Statuses ─────────────────────────────────────────────────────────────
|
||||
@@ -49,9 +63,30 @@ 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),
|
||||
};
|
||||
|
||||
// ── Session Files ─────────────────────────────────────────────────────────────
|
||||
export const sessionFilesApi = {
|
||||
list: (statusId) => api.get(`/daily-statuses/${statusId}/files`).then((r) => r.data),
|
||||
create: (statusId, files) => api.post(`/daily-statuses/${statusId}/files`, files).then((r) => r.data),
|
||||
delete: (id) => api.delete(`/session-files/${id}`),
|
||||
};
|
||||
|
||||
// ── Analyses ──────────────────────────────────────────────────────────────────
|
||||
export const analysesApi = {
|
||||
listForStatus: (statusId) => api.get(`/daily-statuses/${statusId}/analyses`).then((r) => r.data),
|
||||
create: (statusId, data) => api.post(`/daily-statuses/${statusId}/analyses`, data).then((r) => r.data),
|
||||
get: (id) => api.get(`/analyses/${id}`).then((r) => r.data),
|
||||
delete: (id) => api.delete(`/analyses/${id}`),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,734 @@
|
||||
import React, { useState, useMemo, useEffect, useRef } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
Legend, ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { SubjectSidebar } from './SubjectSidebar';
|
||||
import { sortPayloadByValueDesc, toggleInSet, setIdsHidden, serializePlotConfig, readPlotConfig } from '../lib/crossSubjectChart';
|
||||
|
||||
// ── Color palettes ─────────────────────────────────────────────────────────────
|
||||
|
||||
const CAT_COLORS = { Success: '#22c55e', Failure: '#ef4444', Other: '#9ca3af' };
|
||||
const EXTRA_CAT = ['#f59e0b', '#a855f7', '#ec4899', '#14b8a6', '#f97316'];
|
||||
const GROUP_COLORS = ['#6366f1', '#f59e0b', '#22c55e', '#ef4444', '#a855f7', '#14b8a6', '#ec4899', '#f97316', '#84cc16', '#06b6d4'];
|
||||
|
||||
function catColor(cat, idx = 0) {
|
||||
return CAT_COLORS[cat] ?? EXTRA_CAT[idx % EXTRA_CAT.length];
|
||||
}
|
||||
|
||||
// ── Shared tooltip ─────────────────────────────────────────────────────────────
|
||||
|
||||
function ChartTooltip({ active, payload, label, labelKey = 'dateStr', unit = '' }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const header = payload[0]?.payload?.[labelKey] ?? `Day ${label}`;
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded shadow-sm px-3 py-2 text-xs space-y-0.5">
|
||||
<p className="font-semibold text-gray-700 mb-1">{header}</p>
|
||||
{payload.map((p) => (
|
||||
<p key={p.dataKey} style={{ color: p.stroke ?? p.color }}>
|
||||
{p.name}: {p.value != null ? `${p.value}${unit}` : '—'}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Shared helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
const DAYS_REACH_RE = /#?\s*days?\s*reach/i;
|
||||
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 INPUT_CLS = 'w-16 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];
|
||||
|
||||
function defaultXField(fields) {
|
||||
return fields.find((f) => DAYS_REACH_RE.test(f.label))?.fieldId ?? '__days__';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function buildTicks(data, step) {
|
||||
const xs = data.map((d) => d.x).filter((x) => x != null && isFinite(x));
|
||||
if (!xs.length) return undefined;
|
||||
const lo = Math.floor(Math.min(...xs));
|
||||
const hi = Math.ceil(Math.max(...xs));
|
||||
const ticks = [];
|
||||
for (let t = Math.ceil(lo / step) * step; t <= hi; t += step) ticks.push(t);
|
||||
if (!ticks.length || ticks[0] > lo) ticks.unshift(lo);
|
||||
if (ticks[ticks.length - 1] < hi) ticks.push(hi);
|
||||
return ticks;
|
||||
}
|
||||
|
||||
function TickStepControl({ value, onChange }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-gray-400">Tick every</span>
|
||||
<div className="flex rounded border border-gray-200 overflow-hidden">
|
||||
{STEP_OPTIONS.map((s) => (
|
||||
<button key={s} type="button" onClick={() => onChange(s)}
|
||||
className={`px-2 py-0.5 transition-colors border-l border-gray-200 first:border-l-0
|
||||
${value === s ? 'bg-gray-700 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Per-chart customisation hook & UI ──────────────────────────────────────────
|
||||
|
||||
const HEIGHT_OPTIONS = [
|
||||
{ label: 'S', value: 140 },
|
||||
{ label: 'M', value: 220 },
|
||||
{ label: 'L', value: 340 },
|
||||
];
|
||||
|
||||
function useChartSettings(defaultHeight, initial = {}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [height, setHeight] = useState(initial.height ?? defaultHeight);
|
||||
const [yMin, setYMin] = useState(initial.yMin ?? '');
|
||||
const [yMax, setYMax] = useState(initial.yMax ?? '');
|
||||
const [xZoomMin, setXZoomMin] = useState(initial.xZoomMin ?? '');
|
||||
const [xZoomMax, setXZoomMax] = useState(initial.xZoomMax ?? '');
|
||||
const [xFieldOverride, setXFieldOverride] = useState(initial.xFieldOverride ?? null); // null → use parent's xField
|
||||
|
||||
const yDomain = useMemo(() => [
|
||||
yMin !== '' ? parseFloat(yMin) : 'auto',
|
||||
yMax !== '' ? parseFloat(yMax) : 'auto',
|
||||
], [yMin, yMax]);
|
||||
|
||||
const xDomain = useMemo(() => {
|
||||
const hasMin = xZoomMin !== '';
|
||||
const hasMax = xZoomMax !== '';
|
||||
if (!hasMin && !hasMax) return null;
|
||||
return [hasMin ? parseFloat(xZoomMin) : 'dataMin', hasMax ? parseFloat(xZoomMax) : 'dataMax'];
|
||||
}, [xZoomMin, xZoomMax]);
|
||||
|
||||
const isDirty = yMin !== '' || yMax !== '' || xZoomMin !== '' || xZoomMax !== ''
|
||||
|| xFieldOverride !== null || height !== defaultHeight;
|
||||
|
||||
const snapshot = useMemo(
|
||||
() => ({ height, yMin, yMax, xZoomMin, xZoomMax, xFieldOverride }),
|
||||
[height, yMin, yMax, xZoomMin, xZoomMax, xFieldOverride],
|
||||
);
|
||||
|
||||
function reset() {
|
||||
setHeight(defaultHeight);
|
||||
setYMin(''); setYMax('');
|
||||
setXZoomMin(''); setXZoomMax('');
|
||||
setXFieldOverride(null);
|
||||
}
|
||||
|
||||
return {
|
||||
expanded, setExpanded,
|
||||
height, setHeight,
|
||||
yMin, setYMin, yMax, setYMax,
|
||||
xZoomMin, setXZoomMin, xZoomMax, setXZoomMax,
|
||||
xFieldOverride, setXFieldOverride,
|
||||
yDomain, xDomain, isDirty, reset, snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
function ChartHeader({ title, isDirty, expanded, onToggle }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-xs text-gray-500 font-medium">{title}</p>
|
||||
<button type="button" onClick={onToggle}
|
||||
className={[
|
||||
'text-xs px-2 py-0.5 rounded border transition-colors',
|
||||
expanded
|
||||
? 'bg-gray-100 border-gray-300 text-gray-700'
|
||||
: isDirty
|
||||
? 'bg-indigo-50 border-indigo-200 text-indigo-600'
|
||||
: 'bg-white border-gray-200 text-gray-400 hover:text-gray-700 hover:border-gray-300',
|
||||
].join(' ')}
|
||||
>
|
||||
{expanded ? '▲ collapse' : '▾ customize'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Shown only when the chart card is expanded — renders per-chart controls.
|
||||
function ChartSettingsBar({ s, xAxisFields, globalXField, extra }) {
|
||||
return (
|
||||
<div className="mb-3 p-3 bg-gray-50 border border-gray-100 rounded-lg flex flex-wrap gap-x-5 gap-y-2 items-center text-xs">
|
||||
|
||||
{/* Size */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-400 shrink-0">Size:</span>
|
||||
<div className="flex rounded border border-gray-200 overflow-hidden">
|
||||
{HEIGHT_OPTIONS.map((opt) => (
|
||||
<button key={opt.value} type="button" onClick={() => s.setHeight(opt.value)}
|
||||
className={`px-2 py-0.5 transition-colors border-l border-gray-200 first:border-l-0
|
||||
${s.height === opt.value ? 'bg-gray-700 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Y range */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-400 shrink-0">Y range:</span>
|
||||
<input type="number" placeholder="min" value={s.yMin} onChange={(e) => s.setYMin(e.target.value)} className={INPUT_CLS} />
|
||||
<span className="text-gray-300">–</span>
|
||||
<input type="number" placeholder="max" value={s.yMax} onChange={(e) => s.setYMax(e.target.value)} className={INPUT_CLS} />
|
||||
</div>
|
||||
|
||||
{/* X zoom */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-400 shrink-0">X zoom:</span>
|
||||
<input type="number" placeholder="min" value={s.xZoomMin} onChange={(e) => s.setXZoomMin(e.target.value)} className={INPUT_CLS} />
|
||||
<span className="text-gray-300">–</span>
|
||||
<input type="number" placeholder="max" value={s.xZoomMax} onChange={(e) => s.setXZoomMax(e.target.value)} className={INPUT_CLS} />
|
||||
</div>
|
||||
|
||||
{/* Per-chart X axis field override */}
|
||||
{xAxisFields.length > 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-400 shrink-0">X data:</span>
|
||||
<select
|
||||
value={s.xFieldOverride ?? globalXField}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
s.setXFieldOverride(v === globalXField ? null : v);
|
||||
}}
|
||||
className={SELECT_CLS}
|
||||
>
|
||||
<option value="__days__"># Days Reach (ordinal)</option>
|
||||
{xAxisFields.map((f) => <option key={f.fieldId} value={f.fieldId}>{f.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Slot for chart-specific controls (e.g. Y line visibility) */}
|
||||
{extra}
|
||||
|
||||
{s.isDirty && (
|
||||
<button type="button" onClick={s.reset}
|
||||
className="text-xs text-red-400 hover:text-red-600 transition-colors ml-auto">
|
||||
↺ Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Pure data-computation helpers ──────────────────────────────────────────────
|
||||
|
||||
function computeSubjectChartData(xField, statuses, xAxisFields) {
|
||||
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 };
|
||||
}
|
||||
|
||||
function computeExpChartData(xField, chartType, metric, animalRows, lines, xAxisFields) {
|
||||
// chartType: 'count' | 'rate'
|
||||
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);
|
||||
}
|
||||
|
||||
if (xField === '__days__') {
|
||||
const maxDay = Math.max(0, ...lines.map((l) => animalRows[l.id].length));
|
||||
if (maxDay === 0) return { data: [], xLabel };
|
||||
const data = Array.from({ length: maxDay }, (_, i) => ({ x: i + 1 }));
|
||||
for (const line of lines) {
|
||||
animalRows[line.id].forEach((row, i) => {
|
||||
const s = row.analysis_summary;
|
||||
if (chartType === 'count') {
|
||||
data[i][line.id] = metric === 'total' ? s.total : (s.counts?.[metric] ?? null);
|
||||
} else {
|
||||
data[i][line.id] = s.success_rate != null ? +(s.success_rate * 100).toFixed(1) : null;
|
||||
}
|
||||
});
|
||||
}
|
||||
return { data, xLabel };
|
||||
}
|
||||
|
||||
// Field-based x axis
|
||||
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 { data: [], xLabel };
|
||||
|
||||
const data = sortedX.map((x) => {
|
||||
const pt = { x };
|
||||
for (const p of allPts.filter((pp) => pp.x === x)) {
|
||||
const s = p.summary;
|
||||
if (chartType === 'count') {
|
||||
pt[p.animalId] = metric === 'total' ? s.total : (s.counts?.[metric] ?? null);
|
||||
} else {
|
||||
pt[p.animalId] = s.success_rate != null ? +(s.success_rate * 100).toFixed(1) : null;
|
||||
}
|
||||
}
|
||||
return pt;
|
||||
});
|
||||
return { data, xLabel };
|
||||
}
|
||||
|
||||
// ── Subject charts ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function SubjectAnalysisCharts({ statuses, dailyTemplate }) {
|
||||
const xAxisFields = useMemo(
|
||||
() => (dailyTemplate ?? []).filter((f) => f.active),
|
||||
[dailyTemplate],
|
||||
);
|
||||
const [xField, setXField] = useState(() => defaultXField(xAxisFields));
|
||||
const [tickStep, setTickStep] = useState(1);
|
||||
|
||||
const countsS = useChartSettings(200);
|
||||
const rateS = useChartSettings(160);
|
||||
|
||||
// Shared base computation — used by both charts when no override is set
|
||||
const sharedData = useMemo(
|
||||
() => computeSubjectChartData(xField, statuses, xAxisFields),
|
||||
[xField, statuses, xAxisFields],
|
||||
);
|
||||
|
||||
// Per-chart overrides: only recompute when the override differs from the shared field
|
||||
const countsData = useMemo(() => {
|
||||
const ef = countsS.xFieldOverride;
|
||||
if (!ef || ef === xField) return sharedData;
|
||||
return computeSubjectChartData(ef, statuses, xAxisFields);
|
||||
}, [countsS.xFieldOverride, xField, sharedData, statuses, xAxisFields]);
|
||||
|
||||
const rateData = useMemo(() => {
|
||||
const ef = rateS.xFieldOverride;
|
||||
if (!ef || ef === xField) return sharedData;
|
||||
return computeSubjectChartData(ef, statuses, xAxisFields);
|
||||
}, [rateS.xFieldOverride, xField, sharedData, statuses, xAxisFields]);
|
||||
|
||||
const countsTicks = useMemo(() => buildTicks(countsData.data, tickStep), [countsData.data, tickStep]);
|
||||
const rateTicks = useMemo(() => buildTicks(rateData.data, tickStep), [rateData.data, tickStep]);
|
||||
|
||||
// Y-line visibility for the counts chart (null = show all)
|
||||
const [visibleCats, setVisibleCats] = useState(null);
|
||||
useEffect(() => { setVisibleCats(null); }, [countsData.cats.join(',')]); // reset when cats change
|
||||
|
||||
if (sharedData.data.length === 0) return null;
|
||||
|
||||
function toggleCat(cat) {
|
||||
setVisibleCats((prev) => {
|
||||
if (prev === null) return new Set([cat]);
|
||||
const next = new Set(prev);
|
||||
next.has(cat) ? next.delete(cat) : next.add(cat);
|
||||
return next.size === 0 ? null : next;
|
||||
});
|
||||
}
|
||||
|
||||
function xAxisProps(data, ticks, s) {
|
||||
const ef = s.xFieldOverride ?? xField;
|
||||
const xAxisField = xAxisFields.find((f) => f.fieldId === ef);
|
||||
const xLabel = xAxisField ? xAxisField.label : '# Days Reach';
|
||||
const domain = s.xDomain ?? (ticks?.length ? [ticks[0], ticks[ticks.length - 1]] : ['auto', 'auto']);
|
||||
return { dataKey: 'x', type: 'number', tick: { fontSize: 10 }, ticks, domain, allowDecimals: false,
|
||||
label: { value: xLabel, position: 'insideBottomRight', offset: -4, fontSize: 10 } };
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5 mb-6 space-y-5">
|
||||
{/* Global controls */}
|
||||
<div className="flex items-center flex-wrap gap-3">
|
||||
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">Session metrics over time</h2>
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-gray-400">X axis:</span>
|
||||
<select value={xField} onChange={(e) => setXField(e.target.value)} className={SELECT_CLS}>
|
||||
<option value="__days__"># Days Reach (ordinal)</option>
|
||||
{xAxisFields.map((f) => <option key={f.fieldId} value={f.fieldId}>{f.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<TickStepControl value={tickStep} onChange={setTickStep} />
|
||||
</div>
|
||||
|
||||
{/* Chart 1 — counts */}
|
||||
<div>
|
||||
<ChartHeader title="Attempts per session" isDirty={countsS.isDirty} expanded={countsS.expanded} onToggle={() => countsS.setExpanded((e) => !e)} />
|
||||
{countsS.expanded && (
|
||||
<ChartSettingsBar s={countsS} xAxisFields={xAxisFields} globalXField={xField}
|
||||
extra={
|
||||
countsData.cats.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-gray-400 shrink-0">Y lines:</span>
|
||||
{countsData.cats.map((cat, i) => {
|
||||
const on = visibleCats === null || visibleCats.has(cat);
|
||||
return (
|
||||
<button key={cat} type="button" onClick={() => toggleCat(cat)}
|
||||
style={{ color: catColor(cat, i), borderColor: on ? catColor(cat, i) : '#e5e7eb' }}
|
||||
className={`px-2 py-0.5 rounded border text-xs transition-opacity ${on ? 'opacity-100' : 'opacity-35'}`}>
|
||||
{cat}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<ResponsiveContainer width="100%" height={countsS.height}>
|
||||
<LineChart data={countsData.data} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f3f4f6" />
|
||||
<XAxis {...xAxisProps(countsData.data, countsTicks, countsS)} />
|
||||
<YAxis tick={{ fontSize: 10 }} width={30} allowDecimals={false} domain={countsS.yDomain} />
|
||||
<Tooltip content={<ChartTooltip labelKey="dateStr" />} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
{countsData.cats
|
||||
.filter((cat) => visibleCats === null || visibleCats.has(cat))
|
||||
.map((cat, i) => (
|
||||
<Line key={cat} type="monotone" dataKey={cat} stroke={catColor(cat, i)} strokeWidth={2}
|
||||
dot={{ r: 3 }} activeDot={{ r: 4 }} connectNulls isAnimationActive={false} />
|
||||
))}
|
||||
<Line type="monotone" dataKey="total" name="Total" stroke="#3b82f6" strokeWidth={1.5}
|
||||
strokeDasharray="5 3" dot={{ r: 3 }} connectNulls isAnimationActive={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Chart 2 — success rate */}
|
||||
<div>
|
||||
<ChartHeader title="Success rate (%)" isDirty={rateS.isDirty} expanded={rateS.expanded} onToggle={() => rateS.setExpanded((e) => !e)} />
|
||||
{rateS.expanded && (
|
||||
<ChartSettingsBar s={rateS} xAxisFields={xAxisFields} globalXField={xField} />
|
||||
)}
|
||||
<ResponsiveContainer width="100%" height={rateS.height}>
|
||||
<LineChart data={rateData.data} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f3f4f6" />
|
||||
<XAxis {...xAxisProps(rateData.data, rateTicks, rateS)} />
|
||||
<YAxis tick={{ fontSize: 10 }} width={36}
|
||||
domain={rateS.yDomain[0] !== 'auto' || rateS.yDomain[1] !== 'auto' ? rateS.yDomain : [0, 100]}
|
||||
tickFormatter={(v) => `${v}%`} />
|
||||
<Tooltip content={<ChartTooltip unit="%" labelKey="dateStr" />} />
|
||||
<Line type="monotone" dataKey="rate" name="Success rate" stroke="#6366f1" strokeWidth={2}
|
||||
dot={{ r: 3 }} connectNulls isAnimationActive={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Experiment charts ──────────────────────────────────────────────────────────
|
||||
|
||||
const METRIC_OPTIONS = [
|
||||
{ value: 'total', label: 'Total attempts' },
|
||||
{ value: 'Success', label: 'Success count' },
|
||||
{ value: 'Failure', label: 'Failure count' },
|
||||
];
|
||||
|
||||
export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectTemplate, dailyTemplate, plotConfig, onPersistConfig }) {
|
||||
const groupableFields = useMemo(() => (subjectTemplate ?? []).filter((f) => f.active), [subjectTemplate]);
|
||||
const xAxisFields = useMemo(() => (dailyTemplate ?? []).filter((f) => f.active), [dailyTemplate]);
|
||||
|
||||
const defaultGroup = (groupableFields.find((f) => /^group$/i.test(f.label)) ?? groupableFields[0])?.fieldId ?? '';
|
||||
// Read the saved config exactly once (component only mounts after the experiment loads).
|
||||
const initialConfigRef = useRef(null);
|
||||
if (initialConfigRef.current === null) initialConfigRef.current = readPlotConfig(plotConfig);
|
||||
const initialConfig = initialConfigRef.current;
|
||||
const [groupBy, setGroupBy] = useState(initialConfig.groupBy ?? defaultGroup);
|
||||
const [metric, setMetric] = useState(initialConfig.metric ?? 'total');
|
||||
const [xField, setXField] = useState(() => initialConfig.xField ?? defaultXField(xAxisFields));
|
||||
const [tickStep, setTickStep] = useState(initialConfig.tickStep ?? 1);
|
||||
|
||||
// Cross-subject interaction state (client-only; global across both charts)
|
||||
const [sidebarOpen, setSidebarOpen] = useState(initialConfig.sidebarOpen);
|
||||
const [hiddenSubjects, setHiddenSubjects] = useState(() => new Set(initialConfig.hiddenSubjects));
|
||||
const [highlightedSubject, setHighlightedSubject] = useState(null);
|
||||
|
||||
const toggleHidden = (id) => setHiddenSubjects((prev) => toggleInSet(prev, id));
|
||||
const toggleGroup = (ids, hidden) => setHiddenSubjects((prev) => setIdsHidden(prev, ids, hidden));
|
||||
const showAll = () => setHiddenSubjects(new Set());
|
||||
|
||||
const countsS = useChartSettings(200, initialConfig.counts);
|
||||
const rateS = useChartSettings(160, initialConfig.rate);
|
||||
|
||||
// ── Persist display settings (debounced auto-save) ────────────────────────────
|
||||
const [saveState, setSaveState] = useState('idle'); // 'idle' | 'saving' | 'saved' | 'error'
|
||||
|
||||
const currentConfig = useMemo(
|
||||
() => serializePlotConfig({
|
||||
xField, groupBy, tickStep, metric, hiddenSubjects, sidebarOpen,
|
||||
counts: countsS.snapshot, rate: rateS.snapshot,
|
||||
}),
|
||||
[xField, groupBy, tickStep, metric, hiddenSubjects, sidebarOpen, countsS.snapshot, rateS.snapshot],
|
||||
);
|
||||
|
||||
// Keep the latest persist callback in a ref so the save effect does not re-run
|
||||
// when the parent passes a new inline function each render.
|
||||
const persistRef = useRef(onPersistConfig);
|
||||
persistRef.current = onPersistConfig;
|
||||
|
||||
const lastSavedRef = useRef(null);
|
||||
useEffect(() => {
|
||||
const serialized = JSON.stringify(currentConfig);
|
||||
if (lastSavedRef.current === null) { lastSavedRef.current = serialized; return; } // skip initial (hydration)
|
||||
// Reverted to the last-saved value before the debounce fired → nothing pending.
|
||||
if (serialized === lastSavedRef.current) { setSaveState('idle'); return; }
|
||||
if (!persistRef.current) { lastSavedRef.current = serialized; setSaveState('idle'); return; }
|
||||
setSaveState('saving');
|
||||
const t = setTimeout(() => {
|
||||
Promise.resolve(persistRef.current(currentConfig))
|
||||
.then(() => { lastSavedRef.current = serialized; setSaveState('saved'); })
|
||||
.catch(() => setSaveState('error'));
|
||||
}, 1000);
|
||||
return () => clearTimeout(t);
|
||||
}, [currentConfig]);
|
||||
|
||||
// Step 1: animalRows — does NOT depend on xField; shared by both charts
|
||||
const { animalRows, lines } = useMemo(() => {
|
||||
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();
|
||||
|
||||
const statusesByAnimal = {};
|
||||
for (const s of experimentStatuses) {
|
||||
if (!statusesByAnimal[s.animal_id]) statusesByAnimal[s.animal_id] = [];
|
||||
statusesByAnimal[s.animal_id].push(s);
|
||||
}
|
||||
const animalRows = {};
|
||||
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],
|
||||
}));
|
||||
|
||||
return { animalRows, lines };
|
||||
}, [animals, experimentStatuses, groupBy]);
|
||||
|
||||
// Step 2: count chart data — depends on xField for counts + metric
|
||||
const { data: countData, xLabel: countsXLabel } = useMemo(() => {
|
||||
if (lines.length === 0) return { data: [], xLabel: '' };
|
||||
const ef = countsS.xFieldOverride ?? xField;
|
||||
return computeExpChartData(ef, 'count', metric, animalRows, lines, xAxisFields);
|
||||
}, [animalRows, lines, countsS.xFieldOverride, xField, metric, xAxisFields]);
|
||||
|
||||
// Step 3: rate chart data — depends on xField for rate
|
||||
const { data: rateData, xLabel: rateXLabel } = useMemo(() => {
|
||||
if (lines.length === 0) return { data: [], xLabel: '' };
|
||||
const ef = rateS.xFieldOverride ?? xField;
|
||||
return computeExpChartData(ef, 'rate', null, animalRows, lines, xAxisFields);
|
||||
}, [animalRows, lines, rateS.xFieldOverride, xField, xAxisFields]);
|
||||
|
||||
const countsTicks = useMemo(() => buildTicks(countData, tickStep), [countData, tickStep]);
|
||||
const rateTicks = useMemo(() => buildTicks(rateData, tickStep), [rateData, tickStep]);
|
||||
|
||||
const hasData = lines.length > 0;
|
||||
|
||||
function xAxisProps(data, ticks, s, fallbackLabel) {
|
||||
const domain = s.xDomain ?? (ticks?.length ? [ticks[0], ticks[ticks.length - 1]] : ['auto', 'auto']);
|
||||
const label = (s.xFieldOverride ? xAxisFields.find((f) => f.fieldId === s.xFieldOverride)?.label : null) ?? fallbackLabel;
|
||||
return { dataKey: 'x', type: 'number', tick: { fontSize: 10 }, ticks, domain, allowDecimals: false,
|
||||
label: { value: label, position: 'insideBottomRight', offset: -4, fontSize: 10 } };
|
||||
}
|
||||
|
||||
function ExpTooltip({ active, payload, label, unit = '' }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const rows = sortPayloadByValueDesc(payload);
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded shadow-sm px-3 py-2 text-xs space-y-0.5 max-h-48 overflow-y-auto">
|
||||
<p className="font-semibold text-gray-700 mb-1">{label}</p>
|
||||
{rows.map((p) => {
|
||||
const line = lines.find((l) => l.id === p.dataKey);
|
||||
return (
|
||||
<p key={p.dataKey} style={{ color: p.stroke }}>
|
||||
{line?.name ?? p.dataKey}{line?.group && line.group !== '—' ? ` (${line.group})` : ''}: {p.value}{unit}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5 mb-6 space-y-5">
|
||||
{/* Global controls */}
|
||||
<div className="flex items-center flex-wrap gap-3">
|
||||
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">Cross-subject metrics</h2>
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-gray-400">X axis:</span>
|
||||
<select value={xField} onChange={(e) => setXField(e.target.value)} className={SELECT_CLS}>
|
||||
<option value="__days__"># Days Reach (ordinal)</option>
|
||||
{xAxisFields.map((f) => <option key={f.fieldId} value={f.fieldId}>{f.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{groupableFields.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-gray-400">Color by:</span>
|
||||
<select value={groupBy} onChange={(e) => setGroupBy(e.target.value)} className={SELECT_CLS}>
|
||||
<option value="">— none —</option>
|
||||
{groupableFields.map((f) => <option key={f.fieldId} value={f.fieldId}>{f.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<TickStepControl value={tickStep} onChange={setTickStep} />
|
||||
{hasData && (
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{saveState === 'saving' && <span className="text-xs text-gray-400">Saving…</span>}
|
||||
{saveState === 'saved' && <span className="text-xs text-emerald-500">Saved ✓</span>}
|
||||
{saveState === 'error' && <span className="text-xs text-amber-500">Couldn't save layout</span>}
|
||||
<button type="button" onClick={() => setSidebarOpen((o) => !o)}
|
||||
className={[
|
||||
'text-xs px-2 py-0.5 rounded border transition-colors',
|
||||
sidebarOpen
|
||||
? 'bg-gray-100 border-gray-300 text-gray-700'
|
||||
: 'bg-white border-gray-200 text-gray-500 hover:text-gray-700 hover:border-gray-300',
|
||||
].join(' ')}>
|
||||
{sidebarOpen ? '◂ Subjects' : '▸ Subjects'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hasData && (
|
||||
<p className="text-sm text-gray-400 italic">No sessions with saved metrics yet.</p>
|
||||
)}
|
||||
|
||||
{hasData && (
|
||||
<div className="flex gap-3 items-start">
|
||||
<div className="flex-1 min-w-0 space-y-5">
|
||||
{/* Chart 1 — counts */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2 flex-wrap gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-xs text-gray-500 font-medium">Count per session</p>
|
||||
{/* Y-metric selector stays at chart level — primary control */}
|
||||
<div className="flex rounded border border-gray-200 overflow-hidden text-[10px]">
|
||||
{METRIC_OPTIONS.map((opt) => (
|
||||
<button key={opt.value} type="button" onClick={() => setMetric(opt.value)}
|
||||
className={`px-2 py-0.5 transition-colors border-l border-gray-200 first:border-l-0
|
||||
${metric === opt.value ? 'bg-gray-700 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onClick={() => countsS.setExpanded((e) => !e)}
|
||||
className={[
|
||||
'text-xs px-2 py-0.5 rounded border transition-colors',
|
||||
countsS.expanded
|
||||
? 'bg-gray-100 border-gray-300 text-gray-700'
|
||||
: countsS.isDirty
|
||||
? 'bg-indigo-50 border-indigo-200 text-indigo-600'
|
||||
: 'bg-white border-gray-200 text-gray-400 hover:text-gray-700 hover:border-gray-300',
|
||||
].join(' ')}>
|
||||
{countsS.expanded ? '▲ collapse' : '▾ customize'}
|
||||
</button>
|
||||
</div>
|
||||
{countsS.expanded && (
|
||||
<ChartSettingsBar s={countsS} xAxisFields={xAxisFields} globalXField={xField} />
|
||||
)}
|
||||
<ResponsiveContainer width="100%" height={countsS.height}>
|
||||
<LineChart data={countData} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f3f4f6" />
|
||||
<XAxis {...xAxisProps(countData, countsTicks, countsS, countsXLabel)} />
|
||||
<YAxis tick={{ fontSize: 10 }} width={30} allowDecimals={false} domain={countsS.yDomain} />
|
||||
<Tooltip content={<ExpTooltip />} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }}
|
||||
formatter={(value) => lines.find((l) => l.id === value)?.name ?? value}
|
||||
onClick={(o) => toggleHidden(o.dataKey)}
|
||||
onMouseEnter={(o) => setHighlightedSubject(o.dataKey)}
|
||||
onMouseLeave={() => setHighlightedSubject(null)} />
|
||||
{lines.map((l) => (
|
||||
<Line key={l.id} type="monotone" dataKey={l.id} name={l.id}
|
||||
stroke={l.color}
|
||||
strokeWidth={highlightedSubject === l.id ? 3.5 : 2}
|
||||
hide={hiddenSubjects.has(l.id)}
|
||||
dot={{ r: 3 }} activeDot={{ r: 5 }}
|
||||
connectNulls isAnimationActive={false} />
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Chart 2 — success rate */}
|
||||
<div>
|
||||
<ChartHeader title="Success rate (%) — each line is one subject" isDirty={rateS.isDirty} expanded={rateS.expanded} onToggle={() => rateS.setExpanded((e) => !e)} />
|
||||
{rateS.expanded && (
|
||||
<ChartSettingsBar s={rateS} xAxisFields={xAxisFields} globalXField={xField} />
|
||||
)}
|
||||
<ResponsiveContainer width="100%" height={rateS.height}>
|
||||
<LineChart data={rateData} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f3f4f6" />
|
||||
<XAxis {...xAxisProps(rateData, rateTicks, rateS, rateXLabel)} />
|
||||
<YAxis tick={{ fontSize: 10 }} width={36}
|
||||
domain={rateS.yDomain[0] !== 'auto' || rateS.yDomain[1] !== 'auto' ? rateS.yDomain : [0, 100]}
|
||||
tickFormatter={(v) => `${v}%`} />
|
||||
<Tooltip content={<ExpTooltip unit="%" />} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }}
|
||||
formatter={(value) => lines.find((l) => l.id === value)?.name ?? value}
|
||||
onClick={(o) => toggleHidden(o.dataKey)}
|
||||
onMouseEnter={(o) => setHighlightedSubject(o.dataKey)}
|
||||
onMouseLeave={() => setHighlightedSubject(null)} />
|
||||
{lines.map((l) => (
|
||||
<Line key={l.id} type="monotone" dataKey={l.id} name={l.id}
|
||||
stroke={l.color}
|
||||
strokeWidth={highlightedSubject === l.id ? 3.5 : 2}
|
||||
hide={hiddenSubjects.has(l.id)}
|
||||
dot={{ r: 3 }} activeDot={{ r: 5 }}
|
||||
connectNulls isAnimationActive={false} />
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
{sidebarOpen && (
|
||||
<SubjectSidebar
|
||||
lines={lines}
|
||||
hiddenSubjects={hiddenSubjects}
|
||||
onToggleSubject={toggleHidden}
|
||||
onToggleGroup={toggleGroup}
|
||||
onShowAll={showAll}
|
||||
onHighlight={setHighlightedSubject}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<section aria-label="Modification History" className="mt-8">
|
||||
@@ -76,7 +76,7 @@ export default function AuditLogSection({ tableName, recordId }) {
|
||||
|
||||
{logs.length > 0 && (
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden divide-y divide-gray-100">
|
||||
{logs.map((log) => (
|
||||
{logs.slice(0, limit).map((log) => (
|
||||
<div key={log.id} className="bg-white">
|
||||
<button
|
||||
className="w-full text-left px-4 py-3 flex items-center gap-3 hover:bg-gray-50 transition-colors"
|
||||
@@ -108,6 +108,12 @@ export default function AuditLogSection({ tableName, recordId }) {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{total > limit && (
|
||||
<p className="mt-2 text-xs text-gray-400">
|
||||
Showing {limit} of {total} entries — open the daily status page to see the full history.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,744 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { parseCSVHeaders, parseCSV, getUniqueNoteValues, runAnalysis, checkFrameConsecutive, computeNumericSeries } from '../lib/csvAnalysis';
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { analysesApi, dailyStatusesApi, animalsApi } from '../api/client';
|
||||
import Button from './ui/Button';
|
||||
import RunSequenceView from './RunSequenceView';
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_BUCKETS = [
|
||||
{ name: 'Success', type: 'note', notes: [] },
|
||||
{ name: 'Failure', type: 'note', notes: [] },
|
||||
{ name: 'Other', type: 'note', notes: [] },
|
||||
];
|
||||
const DEFAULT_BUCKET_NAMES = new Set(DEFAULT_BUCKETS.map((b) => b.name));
|
||||
|
||||
const CATEGORY_COLORS = {
|
||||
Success: { bg: 'bg-green-100', border: 'border-green-300', text: 'text-green-800', dot: 'bg-green-500' },
|
||||
Failure: { bg: 'bg-red-100', border: 'border-red-300', text: 'text-red-800', dot: 'bg-red-500' },
|
||||
Other: { bg: 'bg-gray-100', border: 'border-gray-300', text: 'text-gray-700', dot: 'bg-gray-400' },
|
||||
};
|
||||
|
||||
const EXTRA_COLORS = [
|
||||
{ bg: 'bg-indigo-100', border: 'border-indigo-300', text: 'text-indigo-800', dot: 'bg-indigo-500' },
|
||||
{ bg: 'bg-yellow-100', border: 'border-yellow-300', text: 'text-yellow-800', dot: 'bg-yellow-500' },
|
||||
{ bg: 'bg-purple-100', border: 'border-purple-300', text: 'text-purple-800', dot: 'bg-purple-500' },
|
||||
{ bg: 'bg-pink-100', border: 'border-pink-300', text: 'text-pink-800', dot: 'bg-pink-500' },
|
||||
{ bg: 'bg-teal-100', border: 'border-teal-300', text: 'text-teal-800', dot: 'bg-teal-500' },
|
||||
];
|
||||
|
||||
function getCategoryColors(cat, customCategories) {
|
||||
if (CATEGORY_COLORS[cat]) return CATEGORY_COLORS[cat];
|
||||
const idx = customCategories.indexOf(cat) % EXTRA_COLORS.length;
|
||||
return EXTRA_COLORS[Math.max(0, idx)];
|
||||
}
|
||||
|
||||
// ── Sub-components ─────────────────────────────────────────────────────────────
|
||||
|
||||
function NoteChip({ value, category, colors, selected, onSelect, draggable, onDragStart }) {
|
||||
return (
|
||||
<span
|
||||
draggable={draggable}
|
||||
onDragStart={onDragStart}
|
||||
onClick={onSelect}
|
||||
title={category ? `Assigned to: ${category}` : 'Unassigned — click or drag to assign'}
|
||||
className={`
|
||||
inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-mono font-semibold
|
||||
border cursor-pointer select-none transition-all
|
||||
${colors ? `${colors.bg} ${colors.border} ${colors.text}` : 'bg-white border-gray-300 text-gray-700 hover:border-indigo-400'}
|
||||
${selected ? 'ring-2 ring-indigo-500 ring-offset-1' : ''}
|
||||
`}
|
||||
>
|
||||
{value}
|
||||
{category && (
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${colors?.dot}`} />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryBucket({
|
||||
name, type, notes, colors, isOver, isCustom, unassignedCount,
|
||||
onDrop, onDragOver, onDragLeave, onRemoveNote, onClickAssign,
|
||||
onBulkAddUnassigned, onRemoveBucket,
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); onDragOver(); }}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={(e) => { e.preventDefault(); onDrop(); }}
|
||||
onClick={onClickAssign}
|
||||
className={`
|
||||
relative group 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'}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className={`text-xs font-semibold uppercase tracking-wide ${colors.text}`}>
|
||||
{name}
|
||||
{type === 'numerical' && (
|
||||
<span className="ml-2 text-[9px] font-bold uppercase bg-white/60 px-1 py-0.5 rounded border border-current/20">
|
||||
numerical
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{isCustom && (
|
||||
<button type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onRemoveBucket(); }}
|
||||
className="text-[10px] text-gray-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Remove this field">✕</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{type === 'numerical' && unassignedCount > 0 && (
|
||||
<button type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onBulkAddUnassigned(); }}
|
||||
className={`text-[10px] mb-2 px-2 py-0.5 rounded border ${colors.border} ${colors.text} hover:bg-white transition-colors`}>
|
||||
+ Add all {unassignedCount} unassigned
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 min-h-[28px]">
|
||||
{notes.length === 0 && (
|
||||
<span className="text-xs text-gray-400 italic">Drop here</span>
|
||||
)}
|
||||
{notes.map((note) => (
|
||||
<span key={note}
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-mono font-semibold ${colors.bg} ${colors.border} ${colors.text} border`}
|
||||
>
|
||||
{note}
|
||||
<button type="button" onClick={(e) => { e.stopPropagation(); onRemoveNote(note); }}
|
||||
className="opacity-50 hover:opacity-100 leading-none">✕</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function CsvAnalysis({ dailyStatusId, animal, 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 [buckets, setBuckets] = useState(() => animal?.csv_analysis_config?.buckets ?? DEFAULT_BUCKETS);
|
||||
const [selectedNote, setSelectedNote] = useState(null);
|
||||
const [draggedNote, setDraggedNote] = useState(null);
|
||||
const [overBucket, setOverBucket] = useState(null);
|
||||
const [newCatName, setNewCatName] = useState('');
|
||||
const [newCatType, setNewCatType] = useState('note');
|
||||
const [showNewCat, setShowNewCat] = useState(false);
|
||||
|
||||
// Derived from buckets (recomputed every render — cheap)
|
||||
const categories = buckets.map((b) => b.name);
|
||||
const customCategories = buckets.filter((b) => !DEFAULT_BUCKET_NAMES.has(b.name)).map((b) => b.name);
|
||||
const noteBuckets = buckets.filter((b) => b.type === 'note');
|
||||
const numericalBuckets = buckets.filter((b) => b.type === 'numerical');
|
||||
const classifications = Object.fromEntries(
|
||||
noteBuckets.flatMap((b) => b.notes.map((n) => [n, b.name]))
|
||||
);
|
||||
|
||||
// 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);
|
||||
const [configSaveError, setConfigSaveError] = 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);
|
||||
setBuckets((prev) => prev.map((b) => ({ ...b, notes: [] })));
|
||||
setSelectedNote(null);
|
||||
if (frameCol) setFrameCheck(checkFrameConsecutive(parsed, frameCol));
|
||||
setParsePending(false);
|
||||
setPhase('classify');
|
||||
}, 20);
|
||||
}
|
||||
|
||||
// ── Classification helpers ───────────────────────────────────────────────────
|
||||
|
||||
function assign(note, category) {
|
||||
setBuckets((prev) => prev.map((b) => {
|
||||
if (b.name === category) {
|
||||
return b.notes.includes(note) ? b : { ...b, notes: [...b.notes, note] };
|
||||
}
|
||||
// Remove from any other bucket so a note belongs to at most one
|
||||
return b.notes.includes(note) ? { ...b, notes: b.notes.filter((n) => n !== note) } : b;
|
||||
}));
|
||||
setSelectedNote(null);
|
||||
}
|
||||
|
||||
function unassign(note) {
|
||||
setBuckets((prev) => prev.map((b) =>
|
||||
b.notes.includes(note) ? { ...b, notes: b.notes.filter((n) => n !== note) } : b
|
||||
));
|
||||
}
|
||||
|
||||
function addBucket(name, type) {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed || buckets.some((b) => b.name === trimmed)) return;
|
||||
setBuckets((prev) => [...prev, { name: trimmed, type, notes: [] }]);
|
||||
}
|
||||
|
||||
const unassignedNotes = uniqueNotes.filter(n => !classifications[n]);
|
||||
const allAssigned = uniqueNotes.length > 0 && unassignedNotes.length === 0;
|
||||
|
||||
// ── Run analysis ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function compute() {
|
||||
// Note-only classifications so numerical buckets don't pollute counts/runs
|
||||
const noteClassifications = Object.fromEntries(
|
||||
noteBuckets.flatMap((b) => b.notes.map((n) => [n, b.name]))
|
||||
);
|
||||
const res = runAnalysis(rows, timestampCol, noteCol, noteClassifications);
|
||||
|
||||
// Numerical series — one per numerical bucket. xCol = frame if set, else timestamp.
|
||||
const xCol = frameCol || timestampCol;
|
||||
const numericSeries = numericalBuckets.map((b) => ({
|
||||
bucket: b,
|
||||
...computeNumericSeries(rows, xCol, noteCol, b),
|
||||
}));
|
||||
|
||||
setResults({ ...res, numericSeries });
|
||||
setPhase('results');
|
||||
|
||||
// Persist bucket config to the animal so future uploads pre-classify.
|
||||
// Independent of dailyStatusId — runs even for ad-hoc analyses.
|
||||
if (animal?.id) {
|
||||
setConfigSaveError(null);
|
||||
animalsApi.updateCsvConfig(animal.id, { buckets })
|
||||
.catch((err) => setConfigSaveError(err.message ?? 'Failed to save bucket config'));
|
||||
}
|
||||
|
||||
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: noteClassifications,
|
||||
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([]);
|
||||
setBuckets(animal?.csv_analysis_config?.buckets ?? DEFAULT_BUCKETS);
|
||||
setResults(null);
|
||||
setSavedAnalysisId(null);
|
||||
setSummaryPushed(false);
|
||||
setSummaryError(null);
|
||||
setFrameCheck(null);
|
||||
}
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="mt-8 border-t border-gray-200 pt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">CSV Analysis</h2>
|
||||
{phase !== 'upload' && (
|
||||
<button type="button" onClick={reset} className="text-xs text-gray-400 hover:text-gray-600">
|
||||
✕ Start over
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── PHASE: upload ── */}
|
||||
{phase === 'upload' && (
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); setIsDragOver(true); }}
|
||||
onDragLeave={() => setIsDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`
|
||||
border-2 border-dashed rounded-xl p-10 text-center cursor-pointer transition-all
|
||||
${isDragOver ? 'border-indigo-400 bg-indigo-50' : 'border-gray-200 hover:border-gray-300 bg-gray-50'}
|
||||
`}
|
||||
>
|
||||
<input ref={fileInputRef} type="file" accept=".csv" className="hidden"
|
||||
onChange={(e) => readFile(e.target.files[0])} />
|
||||
<p className="text-sm text-gray-500">
|
||||
{isDragOver ? 'Drop CSV file to upload' : 'Drop a CSV file here, or click to browse'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">Columns will be read; data stays in your browser</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── PHASE: columns ── */}
|
||||
{phase === 'columns' && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
<span className="font-medium">{fileName}</span> loaded — {headers.length} columns detected. Select the columns to analyse.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-4 max-w-lg">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Timestamp column</label>
|
||||
<select value={timestampCol} onChange={(e) => setTimestampCol(e.target.value)}
|
||||
className="w-full text-sm border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-400">
|
||||
{headers.map(h => <option key={h} value={h}>{h}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Note column</label>
|
||||
<select value={noteCol} onChange={(e) => setNoteCol(e.target.value)}
|
||||
className="w-full text-sm border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-400">
|
||||
{headers.map(h => <option key={h} value={h}>{h}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
Frame number column <span className="font-normal text-gray-400">(gap check)</span>
|
||||
</label>
|
||||
<select value={frameCol} onChange={(e) => setFrameCol(e.target.value)}
|
||||
className="w-full text-sm border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-400">
|
||||
<option value="">— skip —</option>
|
||||
{headers.map(h => <option key={h} value={h}>{h}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={loadData} disabled={parsePending}>
|
||||
{parsePending ? 'Parsing…' : 'Load note values →'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── PHASE: classify ── */}
|
||||
{phase === 'classify' && (
|
||||
<div className="space-y-5">
|
||||
|
||||
{/* Frame gap check results */}
|
||||
{frameCheck && (
|
||||
<div className={`rounded-lg border px-3 py-2.5 text-xs ${frameCheck.consecutive ? 'bg-green-50 border-green-200 text-green-800' : 'bg-amber-50 border-amber-200 text-amber-800'}`}>
|
||||
<p className="font-semibold mb-1">
|
||||
Frame check ({frameCol}): {frameCheck.consecutive ? `✓ all ${frameCheck.total} frames consecutive` : `⚠ issues found in ${frameCheck.total} frames`}
|
||||
</p>
|
||||
{frameCheck.gaps.length > 0 && (
|
||||
<div className="mb-1">
|
||||
<span className="font-medium">{frameCheck.gaps.length} gap{frameCheck.gaps.length !== 1 ? 's' : ''}:</span>
|
||||
<ul className="mt-0.5 space-y-0.5 max-h-32 overflow-y-auto font-mono">
|
||||
{frameCheck.gaps.map((g, i) => (
|
||||
<li key={i}>row {g.rowIndex + 1}: expected {g.expected}, got {g.actual} (jump +{g.jump})</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{frameCheck.duplicates.length > 0 && (
|
||||
<div>
|
||||
<span className="font-medium">{frameCheck.duplicates.length} duplicate{frameCheck.duplicates.length !== 1 ? 's' : ''}:</span>
|
||||
<ul className="mt-0.5 space-y-0.5 font-mono max-h-20 overflow-y-auto">
|
||||
{frameCheck.duplicates.map((d, i) => (
|
||||
<li key={i}>row {d.rowIndex + 1}: frame {d.value}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-gray-600">
|
||||
Found <span className="font-semibold">{uniqueNotes.length}</span> unique note value{uniqueNotes.length !== 1 ? 's' : ''}.
|
||||
{selectedNote && (
|
||||
<span className="ml-2 text-indigo-600 font-medium">
|
||||
Selected: <span className="font-mono">{selectedNote}</span> — click a category to assign
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Unassigned chips */}
|
||||
{unassignedNotes.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1.5">Unassigned</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{unassignedNotes.map(note => (
|
||||
<NoteChip key={note} value={note} category={null} colors={null}
|
||||
selected={selectedNote === note}
|
||||
onSelect={() => setSelectedNote(prev => prev === note ? null : note)}
|
||||
draggable onDragStart={() => setDraggedNote(note)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Category buckets */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{buckets.map((b) => {
|
||||
const colors = getCategoryColors(b.name, customCategories);
|
||||
const isCustom = !DEFAULT_BUCKET_NAMES.has(b.name);
|
||||
return (
|
||||
<CategoryBucket key={b.name} name={b.name} type={b.type} notes={b.notes} colors={colors}
|
||||
isOver={overBucket === b.name}
|
||||
isCustom={isCustom}
|
||||
unassignedCount={unassignedNotes.length}
|
||||
onDragOver={() => setOverBucket(b.name)}
|
||||
onDragLeave={() => setOverBucket(null)}
|
||||
onDrop={() => {
|
||||
if (draggedNote) { assign(draggedNote, b.name); setDraggedNote(null); setOverBucket(null); }
|
||||
}}
|
||||
onClickAssign={() => { if (selectedNote) assign(selectedNote, b.name); }}
|
||||
onRemoveNote={(note) => unassign(note)}
|
||||
onBulkAddUnassigned={() => {
|
||||
for (const n of unassignedNotes) assign(n, b.name);
|
||||
}}
|
||||
onRemoveBucket={() => setBuckets((prev) => prev.filter((x) => x.name !== b.name))}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Add new bucket — note or numerical */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{showNewCat ? (
|
||||
<>
|
||||
<input
|
||||
autoFocus
|
||||
value={newCatName}
|
||||
onChange={(e) => setNewCatName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Escape') setShowNewCat(false); }}
|
||||
placeholder="Field 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"
|
||||
/>
|
||||
<label className="text-xs text-gray-600 inline-flex items-center gap-1">
|
||||
<input type="radio" name="newBucketType" value="note"
|
||||
checked={newCatType === 'note'}
|
||||
onChange={() => setNewCatType('note')} /> Note
|
||||
</label>
|
||||
<label className="text-xs text-gray-600 inline-flex items-center gap-1">
|
||||
<input type="radio" name="newBucketType" value="numerical"
|
||||
checked={newCatType === 'numerical'}
|
||||
onChange={() => setNewCatType('numerical')} /> Numerical
|
||||
</label>
|
||||
<Button size="sm" type="button" onClick={() => {
|
||||
addBucket(newCatName, newCatType);
|
||||
setNewCatName('');
|
||||
setNewCatType('note');
|
||||
setShowNewCat(false);
|
||||
}}>Add</Button>
|
||||
<button type="button" onClick={() => setShowNewCat(false)}
|
||||
className="text-xs text-gray-400 hover:text-gray-600">Cancel</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" onClick={() => setShowNewCat(true)}
|
||||
className="text-xs text-indigo-500 hover:text-indigo-700 border border-dashed border-indigo-300 rounded-full px-3 py-0.5 hover:border-indigo-500 transition-colors">
|
||||
+ Add field
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Unclassified warning */}
|
||||
{!allAssigned && uniqueNotes.length > 0 && (
|
||||
<p className="text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded px-3 py-1.5">
|
||||
{unassignedNotes.length} unassigned note value{unassignedNotes.length !== 1 ? 's' : ''} will be excluded from the analysis.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button onClick={compute} disabled={uniqueNotes.length === 0}>
|
||||
Run analysis →
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── PHASE: results ── */}
|
||||
{phase === 'results' && results && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-500">
|
||||
Analysed <span className="font-semibold">{results.totalNoteRows}</span> note rows
|
||||
· columns: <span className="font-mono text-xs">{timestampCol}</span> / <span className="font-mono text-xs">{noteCol}</span>
|
||||
</p>
|
||||
{dailyStatusId && (
|
||||
<span className="text-xs">
|
||||
{saving && <span className="text-gray-400">Saving…</span>}
|
||||
{!saving && !saveError && results && <span className="text-green-600">✓ Saved</span>}
|
||||
{saveError && <span className="text-red-500" title={saveError}>⚠ Save failed</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Category counts */}
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">Category Counts</h3>
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">Category</th>
|
||||
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">Note breakdown</th>
|
||||
<th className="text-right px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(results.categoryCounts)
|
||||
.sort(([, a], [, b]) => b.total - a.total)
|
||||
.map(([cat, data]) => {
|
||||
const colors = getCategoryColors(cat, customCategories);
|
||||
const breakdown = Object.entries(data.byNote)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([n, c]) => `${n}: ${c}`)
|
||||
.join(' · ');
|
||||
return (
|
||||
<tr key={cat} className="border-b border-gray-100">
|
||||
<td className="px-3 py-2">
|
||||
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-semibold ${colors.bg} ${colors.text}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${colors.dot}`} />
|
||||
{cat}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-500 font-mono text-xs">{breakdown}</td>
|
||||
<td className="px-3 py-2 text-right font-semibold text-gray-800">{data.total}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{Object.keys(results.categoryCounts).length === 0 && (
|
||||
<tr><td colSpan={3} className="px-3 py-4 text-center text-gray-400 italic text-sm">No classified notes</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Consecutive stats */}
|
||||
{Object.keys(results.consecutiveStats).length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">Consecutive Runs</h3>
|
||||
<p className="text-xs text-gray-400 mb-2">
|
||||
How many times each category appeared in a row (run-length encoding of the category sequence, ordered by timestamp).
|
||||
</p>
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">Category</th>
|
||||
<th className="text-right px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">Total runs</th>
|
||||
<th className="text-right px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">Max run</th>
|
||||
<th className="text-right px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">Avg run</th>
|
||||
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">Run lengths</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(results.consecutiveStats)
|
||||
.sort(([, a], [, b]) => b.totalRuns - a.totalRuns)
|
||||
.map(([cat, s]) => {
|
||||
const colors = getCategoryColors(cat, customCategories);
|
||||
const runSummary = s.runLengths
|
||||
.reduce((acc, len) => {
|
||||
acc[len] = (acc[len] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
const runDisplay = Object.entries(runSummary)
|
||||
.sort(([a], [b]) => Number(a) - Number(b))
|
||||
.map(([len, cnt]) => `${len}×${cnt}`)
|
||||
.join(', ');
|
||||
return (
|
||||
<tr key={cat} className="border-b border-gray-100">
|
||||
<td className="px-3 py-2">
|
||||
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-semibold ${colors.bg} ${colors.text}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${colors.dot}`} />
|
||||
{cat}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-gray-700">{s.totalRuns}</td>
|
||||
<td className="px-3 py-2 text-right text-gray-700">{s.maxRun}</td>
|
||||
<td className="px-3 py-2 text-right text-gray-700">{s.avgRun.toFixed(2)}</td>
|
||||
<td className="px-3 py-2 text-gray-500 font-mono text-xs">{runDisplay}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RunSequenceView
|
||||
runs={results.runs}
|
||||
sequence={results.sequence}
|
||||
customCategories={customCategories}
|
||||
sessionEndTs={results.sessionEndTs}
|
||||
/>
|
||||
|
||||
{/* Numerical plots — one panel per numerical bucket */}
|
||||
{results.numericSeries && results.numericSeries.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">Numerical Plots</h3>
|
||||
<div className="space-y-4">
|
||||
{results.numericSeries.map(({ bucket, points, avg, skipped, total, xLabel }) => {
|
||||
const colors = getCategoryColors(bucket.name, customCategories);
|
||||
return (
|
||||
<div key={bucket.name} className="border border-gray-200 rounded-lg p-3">
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<p className={`text-sm font-semibold ${colors.text}`}>
|
||||
{bucket.name} <span className="text-xs text-gray-400 font-normal">vs {xLabel}</span>
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{avg != null ? <>Avg: <strong className="text-gray-800">{avg.toFixed(2)}</strong> · </> : null}
|
||||
{points.length}/{total} rows plottable
|
||||
{skipped > 0 && <> · {skipped} skipped</>}
|
||||
</p>
|
||||
</div>
|
||||
{points.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<LineChart data={points} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#eee" />
|
||||
<XAxis dataKey="x" type="number" domain={['dataMin', 'dataMax']}
|
||||
tick={{ fontSize: 10 }} label={{ value: xLabel, position: 'insideBottom', offset: -2, fontSize: 10 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="value" stroke="currentColor" className={colors.text}
|
||||
dot={{ r: 2 }} strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400 italic">
|
||||
No plottable values — {total} of {total} note rows had no extractable number.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save metrics to daily status */}
|
||||
{dailyStatusId && (
|
||||
<div className="pt-3 border-t border-gray-100">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<button type="button" onClick={pushSummary} disabled={summaryPushing}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 transition-colors">
|
||||
{summaryPushing ? 'Saving…' : summaryPushed ? '✓ Metrics saved to daily status' : 'Save metrics to daily status'}
|
||||
</button>
|
||||
{summaryError && <span className="text-xs text-red-500">{summaryError}</span>}
|
||||
</div>
|
||||
{!summaryPushed && (
|
||||
<p className="text-[10px] text-gray-400 mt-1">
|
||||
Saves success / failure counts, total attempts, and success rate to this day's record for cross-day and cross-subject graphing.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{configSaveError && (
|
||||
<p className="text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded px-3 py-1.5">
|
||||
Bucket config didn't save: {configSaveError}. Analysis still ran. Try running again to retry.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-3 pt-2 border-t border-gray-100">
|
||||
<Button variant="secondary" onClick={() => setPhase('classify')}>← Reclassify</Button>
|
||||
<Button variant="secondary" onClick={reset}>Start over</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +1,132 @@
|
||||
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';
|
||||
|
||||
export default function DailyStatusForm({ existing, animalId, onSuccess, onCancel }) {
|
||||
const BUILTIN_KEYS = new Set(['experiment_description', 'vitals', 'treatment', 'notes']);
|
||||
const EMPTY_TEMPLATE = [];
|
||||
|
||||
// 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) {
|
||||
if (!status) return '';
|
||||
if (field.builtin) return status[field.key] ?? '';
|
||||
return status.custom_fields?.[field.fieldId] ?? '';
|
||||
}
|
||||
|
||||
export default function DailyStatusForm({ existing, animalId, experimentId, template = EMPTY_TEMPLATE, onTemplateUpdate, onSuccess, onCancel, onRequestDelete, hasPrev, hasNext, onNavigate }) {
|
||||
const today = format(new Date(), 'yyyy-MM-dd');
|
||||
const [fields, setFields] = useState({
|
||||
date: existing ? format(new Date(existing.date), 'yyyy-MM-dd') : today,
|
||||
experiment_description: existing?.experiment_description ?? '',
|
||||
vitals: existing?.vitals ?? '',
|
||||
treatment: existing?.treatment ?? '',
|
||||
notes: existing?.notes ?? '',
|
||||
});
|
||||
|
||||
// Active fields from template (date is always first and handled separately)
|
||||
const activeFields = template.filter((f) => f.active);
|
||||
|
||||
function buildInitialValues() {
|
||||
const vals = { date: existing ? String(existing.date).slice(0, 10) : today };
|
||||
for (const f of activeFields) {
|
||||
vals[f.key] = getValue(existing, f);
|
||||
}
|
||||
return vals;
|
||||
}
|
||||
|
||||
const [values, setValues] = useState(buildInitialValues);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [apiError, setApiError] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [savingTagFor, setSavingTagFor] = useState(null); // fieldId currently being saved
|
||||
|
||||
useEffect(() => {
|
||||
setFields({
|
||||
date: existing ? format(new Date(existing.date), 'yyyy-MM-dd') : today,
|
||||
experiment_description: existing?.experiment_description ?? '',
|
||||
vitals: existing?.vitals ?? '',
|
||||
treatment: existing?.treatment ?? '',
|
||||
notes: existing?.notes ?? '',
|
||||
});
|
||||
setValues(buildInitialValues());
|
||||
setErrors({});
|
||||
setApiError(null);
|
||||
}, [existing]);
|
||||
}, [existing, template]);
|
||||
|
||||
const set = (k) => (e) => setFields((f) => ({ ...f, [k]: e.target.value }));
|
||||
const set = (key) => (e) => setValues((v) => ({ ...v, [key]: e.target.value }));
|
||||
|
||||
async function saveTag(field) {
|
||||
const value = (values[field.key] ?? '').trim();
|
||||
if (!value || !experimentId || !onTemplateUpdate) return;
|
||||
setSavingTagFor(field.fieldId);
|
||||
try {
|
||||
const updatedTemplate = template.map((f) =>
|
||||
f.fieldId === field.fieldId
|
||||
? { ...f, tags: [...(f.tags ?? []), value] }
|
||||
: f
|
||||
);
|
||||
await experimentsApi.updateTemplate(experimentId, updatedTemplate);
|
||||
onTemplateUpdate(updatedTemplate);
|
||||
} catch (err) {
|
||||
// non-critical — silently ignore tag save errors
|
||||
} finally {
|
||||
setSavingTagFor(null);
|
||||
}
|
||||
}
|
||||
|
||||
function validate() {
|
||||
const e = {};
|
||||
if (!fields.date) {
|
||||
if (!values.date) {
|
||||
e.date = 'Date is required';
|
||||
} else if (!/^\d{4}-\d{2}-\d{2}$/.test(fields.date)) {
|
||||
e.date = 'Date must be in YYYY-MM-DD format';
|
||||
} else {
|
||||
const d = new Date(fields.date);
|
||||
if (isNaN(d.getTime())) e.date = 'Invalid date';
|
||||
} else if (!/^\d{4}-\d{2}-\d{2}$/.test(values.date) || isNaN(new Date(values.date).getTime())) {
|
||||
e.date = 'Date must be a valid YYYY-MM-DD date';
|
||||
}
|
||||
setErrors(e);
|
||||
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 {
|
||||
const payload = {
|
||||
date: fields.date,
|
||||
experiment_description: fields.experiment_description || null,
|
||||
vitals: fields.vitals || null,
|
||||
treatment: fields.treatment || null,
|
||||
notes: fields.notes || null,
|
||||
};
|
||||
const result = existing
|
||||
? await dailyStatusesApi.update(existing.id, payload)
|
||||
: await dailyStatusesApi.create({ ...payload, animal_id: animalId });
|
||||
onSuccess(result);
|
||||
onSuccess(await doSave());
|
||||
} catch (err) {
|
||||
setApiError(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNavigate(direction) {
|
||||
if (!validate()) return;
|
||||
setLoading(true);
|
||||
setApiError(null);
|
||||
try {
|
||||
const result = await doSave();
|
||||
onNavigate(direction, result);
|
||||
} catch (err) {
|
||||
setApiError(err);
|
||||
} finally {
|
||||
@@ -71,68 +135,93 @@ export default function DailyStatusForm({ existing, animalId, onSuccess, onCance
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} noValidate className="space-y-4">
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
{apiError && (
|
||||
<Alert
|
||||
type="error"
|
||||
message={apiError.message}
|
||||
details={apiError.details}
|
||||
onDismiss={() => setApiError(null)}
|
||||
/>
|
||||
<Alert type="error" message={apiError.message} details={apiError.details} onDismiss={() => setApiError(null)} />
|
||||
)}
|
||||
<FormField label="Date" name="date" error={errors.date} required>
|
||||
<Input
|
||||
type="date"
|
||||
name="date"
|
||||
value={fields.date}
|
||||
onChange={set('date')}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Experiment Description" name="experiment_description">
|
||||
<Textarea
|
||||
name="experiment_description"
|
||||
value={fields.experiment_description}
|
||||
onChange={set('experiment_description')}
|
||||
placeholder="Describe the experimental conditions…"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Vitals" name="vitals">
|
||||
<Input
|
||||
name="vitals"
|
||||
value={fields.vitals}
|
||||
onChange={set('vitals')}
|
||||
placeholder="e.g. HR 72bpm, Temp 37.2°C, Weight 280g"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Treatment" name="treatment">
|
||||
<Input
|
||||
name="treatment"
|
||||
value={fields.treatment}
|
||||
onChange={set('treatment')}
|
||||
placeholder="e.g. Drug X — 10mg/kg oral"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Notes" name="notes">
|
||||
<Textarea
|
||||
name="notes"
|
||||
value={fields.notes}
|
||||
onChange={set('notes')}
|
||||
placeholder="Any additional observations…"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button variant="secondary" type="button" onClick={onCancel} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={loading}>
|
||||
{existing ? 'Save Changes' : 'Log Status'}
|
||||
</Button>
|
||||
|
||||
<div className="grid grid-cols-12 gap-x-3 gap-y-2 mb-4">
|
||||
{/* Date — always full width */}
|
||||
<div className="col-span-12 flex items-center gap-2">
|
||||
<label htmlFor="date" className="text-xs font-medium text-gray-500 whitespace-nowrap w-28 shrink-0 text-right">Date <span className="text-red-400">*</span></label>
|
||||
<div className="w-36">
|
||||
<Input type="date" name="date" value={values.date} onChange={set('date')} required disabled={loading} />
|
||||
{errors.date && <p role="alert" className="text-xs text-red-500 mt-0.5">{errors.date}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dynamic fields */}
|
||||
{activeFields.map((field) => {
|
||||
const isTextarea = field.type === 'textarea';
|
||||
const span = isTextarea ? 12 : widthToSpan(field.width ?? 100);
|
||||
const fieldTags = field.tags ?? [];
|
||||
const currentValue = (values[field.key] ?? '').trim();
|
||||
const canSaveTag = experimentId && onTemplateUpdate && currentValue && !fieldTags.includes(currentValue) && !field.tagsDisabled;
|
||||
return (
|
||||
<div key={field.key} className={`col-span-${span} flex items-start gap-2`}>
|
||||
<label htmlFor={field.key} className="text-xs font-medium text-gray-500 whitespace-nowrap w-28 shrink-0 text-right pt-1.5" title={field.label}>
|
||||
{field.abbr || field.label}{field.unit ? <span className="font-normal text-gray-400"> ({field.unit})</span> : null}
|
||||
</label>
|
||||
<div className="flex-1 min-w-0">
|
||||
{isTextarea ? (
|
||||
<Textarea name={field.key} value={values[field.key] ?? ''} onChange={set(field.key)} disabled={loading} />
|
||||
) : (
|
||||
<Input name={field.key} value={values[field.key] ?? ''} onChange={set(field.key)} disabled={loading} />
|
||||
)}
|
||||
{(fieldTags.length > 0 || canSaveTag) && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 mt-1">
|
||||
{fieldTags.map((tag) => (
|
||||
<button key={tag} type="button" title={`Quick-fill: ${tag}`}
|
||||
onClick={() => setValues((v) => ({ ...v, [field.key]: tag }))}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded-full bg-indigo-50 border border-indigo-200 text-indigo-700 text-xs font-medium hover:bg-indigo-100 transition-colors">
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
{canSaveTag && (
|
||||
<button type="button" onClick={() => saveTag(field)} disabled={savingTagFor === field.fieldId}
|
||||
title="Save current value as a quick-fill tag"
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-dashed border-indigo-300 text-indigo-500 text-xs hover:border-indigo-500 hover:text-indigo-700 transition-colors disabled:opacity-50">
|
||||
{savingTagFor === field.fieldId ? '…' : '+ Save as tag'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{activeFields.length === 0 && (
|
||||
<p className="text-sm text-gray-400 italic text-center py-2">
|
||||
No fields configured. Edit the experiment's Record Template to add fields.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t border-gray-100">
|
||||
<div>
|
||||
{existing && onRequestDelete && (
|
||||
<Button variant="danger" type="button" onClick={onRequestDelete} disabled={loading}>Delete entry</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{existing && onNavigate && (
|
||||
<div className="flex gap-1">
|
||||
<button type="button" onClick={() => handleNavigate('prev')} disabled={loading || !hasPrev}
|
||||
title="Save & go to previous entry"
|
||||
className="px-2 py-1 rounded border border-gray-300 text-gray-500 text-sm hover:bg-gray-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors">
|
||||
←
|
||||
</button>
|
||||
<button type="button" onClick={() => handleNavigate('next')} disabled={loading || !hasNext}
|
||||
title="Save & go to next entry"
|
||||
className="px-2 py-1 rounded border border-gray-300 text-gray-500 text-sm hover:bg-gray-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors">
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<Button variant="secondary" type="button" onClick={onCancel} disabled={loading}>Cancel</Button>
|
||||
<Button type="submit" loading={loading}>{existing ? 'Save Changes' : 'Log Status'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
format, startOfMonth, endOfMonth, eachDayOfInterval,
|
||||
getDay, addMonths, subMonths,
|
||||
} from 'date-fns';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { experimentsApi } from '../api/client';
|
||||
|
||||
export default function ExperimentCalendar({ experimentId, template }) {
|
||||
const navigate = useNavigate();
|
||||
const [currentMonth, setCurrentMonth] = useState(() => new Date());
|
||||
const [selectedField, setSelectedField] = useState(null);
|
||||
const [calendarDays, setCalendarDays] = useState({});
|
||||
|
||||
const fieldOptions = useMemo(
|
||||
() => template.filter((f) => f.active).map((f) => ({ value: f.builtin ? f.key : f.fieldId, label: f.label })),
|
||||
[template],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (fieldOptions.length === 0 || selectedField) return;
|
||||
const weightField = fieldOptions.find((f) => f.label.toLowerCase().includes('weight'));
|
||||
setSelectedField(weightField ? weightField.value : fieldOptions[0]?.value ?? null);
|
||||
}, [fieldOptions]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedField) return;
|
||||
let cancelled = false;
|
||||
experimentsApi.getCalendar(experimentId, selectedField)
|
||||
.then(({ days }) => { if (!cancelled) setCalendarDays(days); })
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [experimentId, selectedField]);
|
||||
|
||||
const monthStart = startOfMonth(currentMonth);
|
||||
const monthEnd = endOfMonth(currentMonth);
|
||||
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
|
||||
const leadingPad = getDay(monthStart);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm mt-6">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-4">Daily Record Calendar</h3>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 mb-5">
|
||||
<span className="text-sm text-gray-600 font-medium">Count by field:</span>
|
||||
<select
|
||||
data-testid="field-select"
|
||||
className="text-sm border border-gray-300 rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white"
|
||||
value={selectedField ?? ''}
|
||||
onChange={(e) => setSelectedField(e.target.value)}
|
||||
disabled={fieldOptions.length === 0}
|
||||
>
|
||||
{fieldOptions.map((f) => <option key={f.value} value={f.value}>{f.label}</option>)}
|
||||
{fieldOptions.length === 0 && <option value="">No fields configured</option>}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button aria-label="Previous month" onClick={() => setCurrentMonth((m) => subMonths(m, 1))}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 text-gray-500 text-lg leading-none">‹</button>
|
||||
<span className="font-semibold text-gray-900 text-base">{format(currentMonth, 'MMMM yyyy')}</span>
|
||||
<button aria-label="Next month" onClick={() => setCurrentMonth((m) => addMonths(m, 1))}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 text-gray-500 text-lg leading-none">›</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 mb-1">
|
||||
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((d) => (
|
||||
<div key={d} className="text-center text-xs font-medium text-gray-400 py-1 select-none">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{Array.from({ length: leadingPad }).map((_, i) => <div key={`pad-${i}`} />)}
|
||||
{days.map((day) => {
|
||||
const dateStr = format(day, 'yyyy-MM-dd');
|
||||
const count = calendarDays[dateStr] ?? 0;
|
||||
const hasData = count > 0;
|
||||
return (
|
||||
<button
|
||||
key={dateStr}
|
||||
data-testid={`day-${dateStr}`}
|
||||
disabled={!hasData}
|
||||
onClick={() => navigate(`/experiments/${experimentId}/day/${dateStr}${selectedField ? `?field=${selectedField}` : ''}`)}
|
||||
className={[
|
||||
'relative flex flex-col items-center justify-center rounded-lg py-1.5 min-h-[2.75rem] text-sm select-none transition-all',
|
||||
hasData ? 'bg-blue-50 border border-blue-200 hover:bg-blue-100 cursor-pointer' : 'text-gray-300 cursor-default',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className={hasData ? 'font-semibold text-blue-900 text-xs' : 'text-xs'}>{day.getDate()}</span>
|
||||
{hasData && (
|
||||
<span data-testid={`count-${dateStr}`} className="text-[10px] leading-none font-bold text-blue-600 mt-0.5">{count}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { experimentsApi } from '../api/client';
|
||||
import Button from './ui/Button';
|
||||
import Alert from './ui/Alert';
|
||||
import { listExportableParams, buildSubjectSeriesMatrix, toCSV, csvFilename } from '../lib/dataExport';
|
||||
|
||||
const PARAM_GROUP_LABELS = { metrics: 'Session metrics', daily: 'Daily record fields' };
|
||||
|
||||
function triggerDownload(filename, text) {
|
||||
const blob = new Blob([text], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
|
||||
export default function ExportDataModal({
|
||||
experimentTitle, experimentId, dailyTemplate, animals, subjectTemplate = [], groupField = '__none__', onClose,
|
||||
}) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [statuses, setStatuses] = useState([]);
|
||||
|
||||
const [xField, setXField] = useState('__date__');
|
||||
const [dataId, setDataId] = useState(null);
|
||||
const [groupBy, setGroupBy] = useState(() => {
|
||||
const valid = groupField === '__none__' || groupField === '__name__' || groupField === '__id__'
|
||||
|| subjectTemplate.some((f) => f.active && f.fieldId === groupField);
|
||||
return valid ? groupField : '__none__';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
experimentsApi.getDailyStatuses(experimentId, {})
|
||||
.then((data) => { if (alive) setStatuses(data); })
|
||||
.catch((err) => { if (alive) setError(err.message); })
|
||||
.finally(() => { if (alive) setLoading(false); });
|
||||
return () => { alive = false; };
|
||||
}, [experimentId]);
|
||||
|
||||
const params = useMemo(() => listExportableParams(dailyTemplate ?? [], statuses), [dailyTemplate, statuses]);
|
||||
const effDataId = dataId ?? params[0]?.id ?? null;
|
||||
const dataParam = params.find((p) => p.id === effDataId) ?? params[0] ?? null;
|
||||
|
||||
const activeDaily = useMemo(() => (dailyTemplate ?? []).filter((f) => f.active), [dailyTemplate]);
|
||||
const xOptions = useMemo(() => [
|
||||
{ id: '__date__', label: 'Date' },
|
||||
{ id: '__days__', label: '# Days Reach (ordinal)' },
|
||||
...activeDaily.map((f) => ({ id: f.fieldId, label: f.label })),
|
||||
], [activeDaily]);
|
||||
|
||||
const matrix = useMemo(
|
||||
() => (dataParam
|
||||
? buildSubjectSeriesMatrix({ xField, dataParam, groupField: groupBy }, { statuses, animals, dailyTemplate })
|
||||
: { columns: [], rows: [], context: { label: 'Data', value: '' }, corner: '', groupAxis: null }),
|
||||
[xField, dataParam, groupBy, statuses, animals, dailyTemplate],
|
||||
);
|
||||
const hasData = matrix.rows.length > 0;
|
||||
|
||||
const groupedParams = useMemo(() => {
|
||||
const out = [];
|
||||
for (const p of params) {
|
||||
let g = out.find((x) => x.group === p.group);
|
||||
if (!g) { g = { group: p.group, items: [] }; out.push(g); }
|
||||
g.items.push(p);
|
||||
}
|
||||
return out;
|
||||
}, [params]);
|
||||
|
||||
function handleExport() {
|
||||
if (!hasData || !dataParam) return;
|
||||
triggerDownload(csvFilename(experimentTitle, dataParam.label), toCSV(matrix));
|
||||
onClose();
|
||||
}
|
||||
|
||||
if (loading) return <p className="text-sm text-gray-400" aria-live="polite">Loading data…</p>;
|
||||
if (error) return (
|
||||
<div className="space-y-4">
|
||||
<Alert type="error" message={error} />
|
||||
<div className="flex justify-end"><Button variant="secondary" onClick={onClose}>Close</Button></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const selectCls = 'w-full border border-gray-200 rounded px-2 py-1.5 text-sm bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400';
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
One value per subject over an X axis — each column is a subject, each row an X value. Blank where a subject has no value.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label htmlFor="export-x" className="block text-xs font-medium text-gray-500 mb-1">X axis (rows)</label>
|
||||
<select id="export-x" value={xField} onChange={(e) => setXField(e.target.value)} className={selectCls}>
|
||||
{xOptions.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="export-data" className="block text-xs font-medium text-gray-500 mb-1">Data (cells)</label>
|
||||
<select id="export-data" value={effDataId ?? ''} onChange={(e) => setDataId(e.target.value)} className={selectCls}>
|
||||
{groupedParams.map((g) => (
|
||||
<optgroup key={g.group} label={PARAM_GROUP_LABELS[g.group] ?? g.group}>
|
||||
{g.items.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="export-group" className="block text-xs font-medium text-gray-500 mb-1">Group by</label>
|
||||
<select id="export-group" value={groupBy} onChange={(e) => setGroupBy(e.target.value)} className={selectCls}>
|
||||
<option value="__none__">— None —</option>
|
||||
<option value="__name__">Name</option>
|
||||
<option value="__id__">Subject ID</option>
|
||||
{subjectTemplate.filter((f) => f.active).map((f) => (
|
||||
<option key={f.fieldId} value={f.fieldId}>{f.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400">
|
||||
{hasData
|
||||
? `${matrix.rows.length} row${matrix.rows.length !== 1 ? 's' : ''} × ${matrix.columns.length} subject${matrix.columns.length !== 1 ? 's' : ''}`
|
||||
: 'No data for this selection yet.'}
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={handleExport} disabled={!hasData}>Export CSV</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { sessionFilesApi } from '../api/client';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function escapeRegex(str) {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a regex that matches an animal's identifier in a filename.
|
||||
* Tries both animal_id_string and animal_name (OR logic).
|
||||
*
|
||||
* Rules:
|
||||
* - Match must not be preceded or followed by an alphanumeric char,
|
||||
* so "2852" matches "rat_2852_session" but not "28520" or "X2852Y".
|
||||
* - Multi-word names ("Rat A") allow any separator (space, dash, underscore)
|
||||
* between words, so "Rat A" matches "rat_a_session" and "Rat-A-data".
|
||||
*/
|
||||
function buildAnimalRegex(animal) {
|
||||
const patterns = [];
|
||||
|
||||
if (animal.animal_id_string) {
|
||||
patterns.push(escapeRegex(animal.animal_id_string));
|
||||
}
|
||||
|
||||
if (animal.animal_name) {
|
||||
const words = animal.animal_name.trim().split(/\s+/).map(escapeRegex);
|
||||
// Single-word name: use as-is. Multi-word: allow space/dash/underscore between words.
|
||||
patterns.push(words.length === 1 ? words[0] : words.join('[\\s_\\-]+'));
|
||||
}
|
||||
|
||||
if (patterns.length === 0) return null;
|
||||
return new RegExp(`(?<![a-zA-Z0-9])(${patterns.join('|')})(?![a-zA-Z0-9])`, 'i');
|
||||
}
|
||||
|
||||
function classifyFile(filename) {
|
||||
const ext = filename.split('.').pop().toLowerCase();
|
||||
const MAP = { csv: 'CSV', tsv: 'TSV', h5: 'HDF5', hdf5: 'HDF5', nwb: 'NWB', bin: 'Binary', dat: 'Binary', raw: 'Binary', npy: 'NumPy', npz: 'NumPy', mat: 'MATLAB', pkl: 'Pickle' };
|
||||
return MAP[ext] ?? 'Binary';
|
||||
}
|
||||
|
||||
function fmtBytes(n) {
|
||||
const num = typeof n === 'bigint' ? Number(n) : Number(n);
|
||||
if (num >= 1e9) return `${(num / 1e9).toFixed(1)} GB`;
|
||||
if (num >= 1e6) return `${(num / 1e6).toFixed(1)} MB`;
|
||||
if (num >= 1e3) return `${(num / 1e3).toFixed(1)} KB`;
|
||||
return `${num} B`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match an array of File objects against animals.
|
||||
* Returns {
|
||||
* matched: Map<animalId, Array<{ file, matchedIdentifier }>>
|
||||
* unmatched: File[]
|
||||
* }
|
||||
* matchedIdentifier is the animal_id_string or animal_name value that triggered the match.
|
||||
*/
|
||||
function matchFilesToAnimals(files, animals) {
|
||||
// Build patterns; each entry carries both alternatives so we know which one matched.
|
||||
const entries = animals
|
||||
.map((animal) => {
|
||||
const alts = [];
|
||||
if (animal.animal_id_string) alts.push({ value: animal.animal_id_string, re: new RegExp(`(?<![a-zA-Z0-9])${escapeRegex(animal.animal_id_string)}(?![a-zA-Z0-9])`, 'i') });
|
||||
if (animal.animal_name) {
|
||||
const words = animal.animal_name.trim().split(/\s+/).map(escapeRegex);
|
||||
const pat = words.length === 1 ? words[0] : words.join('[\\s_\\-]+');
|
||||
alts.push({ value: animal.animal_name, re: new RegExp(`(?<![a-zA-Z0-9])${pat}(?![a-zA-Z0-9])`, 'i') });
|
||||
}
|
||||
return { animal, alts };
|
||||
})
|
||||
.filter((e) => e.alts.length > 0);
|
||||
|
||||
const matched = new Map(animals.map((a) => [a.id, []]));
|
||||
const unmatched = [];
|
||||
|
||||
for (const file of files) {
|
||||
let found = false;
|
||||
for (const { animal, alts } of entries) {
|
||||
for (const { value, re } of alts) {
|
||||
if (re.test(file.name)) {
|
||||
matched.get(animal.id).push({ file, matchedIdentifier: value });
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found) break;
|
||||
}
|
||||
if (!found) unmatched.push(file);
|
||||
}
|
||||
return { matched, unmatched };
|
||||
}
|
||||
|
||||
// ── Sub-components ────────────────────────────────────────────────────────────
|
||||
|
||||
function FileRow({ file, matchedIdentifier, onRemove }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1 gap-2 text-xs">
|
||||
<span className="font-mono text-gray-700 truncate min-w-0 flex-1">{file.name}</span>
|
||||
{matchedIdentifier && (
|
||||
<span className="text-blue-400 shrink-0 font-mono" title="matched by">↳ {matchedIdentifier}</span>
|
||||
)}
|
||||
<span className="text-gray-400 shrink-0">{fmtBytes(file.size)}</span>
|
||||
<span className="text-gray-400 shrink-0 w-12 text-right">{classifyFile(file.name)}</span>
|
||||
{onRemove && (
|
||||
<button type="button" onClick={onRemove}
|
||||
className="text-red-300 hover:text-red-500 transition-colors shrink-0 ml-1">✕</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SavedFileRow({ file, onDelete, deleting }) {
|
||||
const size = typeof file.file_size === 'string' ? BigInt(file.file_size) : file.file_size;
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1 gap-2 text-xs">
|
||||
<span className="font-mono text-gray-600 truncate min-w-0 flex-1">{file.filename}</span>
|
||||
<span className="text-gray-400 shrink-0">{fmtBytes(size)}</span>
|
||||
<span className="text-gray-400 shrink-0 w-12 text-right">{file.file_type ?? '?'}</span>
|
||||
<button type="button" onClick={onDelete} disabled={deleting}
|
||||
className="text-red-300 hover:text-red-500 transition-colors shrink-0 ml-1 disabled:opacity-40">✕</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* FileDropZone
|
||||
*
|
||||
* Props:
|
||||
* rows — array of { animal, status } from ExperimentDayView
|
||||
* (only rows with a status are shown; status.id is required to save files)
|
||||
* date — YYYY-MM-DD string, shown in naming hint
|
||||
*/
|
||||
export default function FileDropZone({ rows, date }) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dropped, setDropped] = useState(null); // { matched: Map, unmatched: File[] } | null
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState(null);
|
||||
const [savedByStatus, setSavedByStatus] = useState({}); // statusId → SavedFile[]
|
||||
const [loadingIds, setLoadingIds] = useState(new Set());
|
||||
const [deletingId, setDeletingId] = useState(null);
|
||||
const dragCounter = useRef(0);
|
||||
|
||||
const statusRows = rows.filter((r) => r.status);
|
||||
const animals = statusRows.map((r) => r.animal);
|
||||
|
||||
// Load existing saved files for all statuses in this day view
|
||||
useEffect(() => {
|
||||
if (statusRows.length === 0) return;
|
||||
const ids = statusRows.map((r) => r.status.id);
|
||||
setLoadingIds(new Set(ids));
|
||||
Promise.all(
|
||||
ids.map((sid) => sessionFilesApi.list(sid).then((files) => ({ sid, files })))
|
||||
).then((results) => {
|
||||
const map = {};
|
||||
results.forEach(({ sid, files }) => { map[sid] = files; });
|
||||
setSavedByStatus(map);
|
||||
setLoadingIds(new Set());
|
||||
}).catch(() => setLoadingIds(new Set()));
|
||||
}, [statusRows.map((r) => r.status?.id).join(',')]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── Drag handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
const onDragEnter = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
dragCounter.current += 1;
|
||||
if (dragCounter.current === 1) setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const onDragLeave = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
dragCounter.current -= 1;
|
||||
if (dragCounter.current === 0) setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const onDragOver = useCallback((e) => { e.preventDefault(); }, []);
|
||||
|
||||
const onDrop = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
dragCounter.current = 0;
|
||||
setIsDragging(false);
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length === 0) return;
|
||||
setDropped(matchFilesToAnimals(files, animals));
|
||||
setSaveError(null);
|
||||
}, [animals]);
|
||||
|
||||
function removeMatchedFile(animalId, fileIdx) {
|
||||
setDropped((prev) => {
|
||||
const next = new Map(prev.matched);
|
||||
const arr = [...next.get(animalId)];
|
||||
arr.splice(fileIdx, 1);
|
||||
next.set(animalId, arr);
|
||||
return { matched: next, unmatched: prev.unmatched };
|
||||
});
|
||||
}
|
||||
|
||||
function removeUnmatched(idx) {
|
||||
setDropped((prev) => {
|
||||
const u = [...prev.unmatched];
|
||||
u.splice(idx, 1);
|
||||
return { matched: prev.matched, unmatched: u };
|
||||
});
|
||||
}
|
||||
|
||||
// ── Save ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function save() {
|
||||
if (!dropped) return;
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
statusRows.map(async ({ animal, status }) => {
|
||||
const entries = dropped.matched.get(animal.id) ?? [];
|
||||
if (entries.length === 0) return { statusId: status.id, saved: [] };
|
||||
const payload = entries.map(({ file, matchedIdentifier }) => ({
|
||||
filename: file.name,
|
||||
file_size: file.size,
|
||||
file_type: classifyFile(file.name),
|
||||
last_modified: file.lastModified ?? null,
|
||||
matched_identifier: matchedIdentifier,
|
||||
animal_id_string_snap: animal.animal_id_string ?? null,
|
||||
animal_name_snap: animal.animal_name ?? null,
|
||||
}));
|
||||
const saved = await sessionFilesApi.create(status.id, payload);
|
||||
return { statusId: status.id, saved };
|
||||
})
|
||||
);
|
||||
setSavedByStatus((prev) => {
|
||||
const next = { ...prev };
|
||||
results.forEach(({ statusId, saved }) => {
|
||||
next[statusId] = [...(next[statusId] ?? []), ...saved];
|
||||
});
|
||||
return next;
|
||||
});
|
||||
setDropped(null);
|
||||
} catch (err) {
|
||||
setSaveError(err.message ?? 'Failed to save file metadata');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function deleteFile(fileId, statusId) {
|
||||
setDeletingId(fileId);
|
||||
try {
|
||||
await sessionFilesApi.delete(fileId);
|
||||
setSavedByStatus((prev) => ({
|
||||
...prev,
|
||||
[statusId]: prev[statusId].filter((f) => f.id !== fileId),
|
||||
}));
|
||||
} catch {
|
||||
// silently ignore — UI stays consistent, user can retry
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Derived counts ─────────────────────────────────────────────────────────
|
||||
|
||||
const totalDropped = dropped ? [...dropped.matched.values()].reduce((n, a) => n + a.length, 0) : 0; // entries are { file, matchedIdentifier }
|
||||
const totalUnmatched = dropped?.unmatched.length ?? 0;
|
||||
const totalSaved = Object.values(savedByStatus).reduce((n, a) => n + a.length, 0);
|
||||
const hasSaved = totalSaved > 0;
|
||||
const hasMatchedDropped = totalDropped > 0;
|
||||
|
||||
// Hint string for naming convention
|
||||
const sampleId = animals[0]?.animal_id_string || animals[0]?.animal_name || '<id>';
|
||||
const namingHint = `${date}_${sampleId}_session1.csv`;
|
||||
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Session File Registry</h3>
|
||||
<p className="text-xs text-gray-400 mb-4">
|
||||
Drop all session files here. Filenames are matched to subjects by ID —
|
||||
only metadata (name, size) is recorded; files are never uploaded.
|
||||
<br />
|
||||
Naming convention: <span className="font-mono text-gray-500">{namingHint}</span>
|
||||
</p>
|
||||
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
onDragEnter={onDragEnter}
|
||||
onDragLeave={onDragLeave}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
data-testid="drop-zone"
|
||||
className={[
|
||||
'border-2 border-dashed rounded-xl px-6 py-10 text-center transition-all cursor-default select-none',
|
||||
isDragging
|
||||
? 'border-blue-400 bg-blue-50'
|
||||
: 'border-gray-200 bg-gray-50 hover:border-gray-300',
|
||||
].join(' ')}
|
||||
>
|
||||
{isDragging ? (
|
||||
<p className="text-blue-600 font-medium text-sm">Release to register files</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-gray-400 text-sm">Drag & drop session files here</p>
|
||||
<p className="text-gray-300 text-xs mt-1">All subjects at once · files are not uploaded</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dropped files preview */}
|
||||
{dropped && (
|
||||
<div className="mt-4 space-y-4" data-testid="drop-preview">
|
||||
|
||||
{/* Per-subject matched files */}
|
||||
{statusRows.map(({ animal, status }) => {
|
||||
const entries = dropped.matched.get(animal.id) ?? [];
|
||||
if (entries.length === 0) return null;
|
||||
return (
|
||||
<div key={animal.id} className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-semibold text-gray-800">
|
||||
{animal.animal_name}
|
||||
{animal.animal_id_string && (
|
||||
<span className="ml-1.5 text-xs font-normal text-gray-400">{animal.animal_id_string}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">{entries.length} file{entries.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-50">
|
||||
{entries.map(({ file, matchedIdentifier }, i) => (
|
||||
<FileRow key={file.name + i} file={file} matchedIdentifier={matchedIdentifier} onRemove={() => removeMatchedFile(animal.id, i)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Subjects with no matched files */}
|
||||
{statusRows.some(({ animal }) => (dropped.matched.get(animal.id) ?? []).length === 0) && (
|
||||
<div className="bg-amber-50 border border-amber-100 rounded-xl p-4">
|
||||
<p className="text-xs font-semibold text-amber-700 mb-1">No files matched for:</p>
|
||||
{statusRows
|
||||
.filter(({ animal }) => (dropped.matched.get(animal.id) ?? []).length === 0)
|
||||
.map(({ animal }) => (
|
||||
<span key={animal.id} className="inline-block text-xs text-amber-600 bg-amber-100 rounded px-2 py-0.5 mr-1 mb-1 font-mono">
|
||||
{animal.animal_id_string || animal.animal_name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unmatched files */}
|
||||
{dropped.unmatched.length > 0 && (
|
||||
<div className="bg-gray-50 border border-dashed border-gray-200 rounded-xl p-4">
|
||||
<p className="text-xs font-semibold text-gray-500 mb-2">
|
||||
Unmatched ({dropped.unmatched.length}) — no subject ID found in filename
|
||||
</p>
|
||||
<div className="divide-y divide-gray-100">
|
||||
{dropped.unmatched.map((f, i) => (
|
||||
<FileRow key={f.name + i} file={f} onRemove={() => removeUnmatched(i)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saveError && (
|
||||
<p className="text-xs text-red-500 px-1">{saveError}</p>
|
||||
)}
|
||||
|
||||
{/* Action row */}
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
{hasMatchedDropped && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={saving}
|
||||
data-testid="save-btn"
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saving ? 'Saving…' : `Register ${totalDropped} file${totalDropped !== 1 ? 's' : ''}`}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setDropped(null); setSaveError(null); }}
|
||||
className="text-sm text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Saved files */}
|
||||
{hasSaved && (
|
||||
<div className="mt-6 space-y-3" data-testid="saved-files">
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Registered files ({totalSaved})
|
||||
</p>
|
||||
{statusRows.map(({ animal, status }) => {
|
||||
const files = savedByStatus[status.id] ?? [];
|
||||
if (files.length === 0) return null;
|
||||
return (
|
||||
<div key={animal.id} className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-semibold text-gray-800">
|
||||
{animal.animal_name}
|
||||
{animal.animal_id_string && (
|
||||
<span className="ml-1.5 text-xs font-normal text-gray-400">{animal.animal_id_string}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">{files.length} file{files.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-50">
|
||||
{files.map((f) => (
|
||||
<SavedFileRow
|
||||
key={f.id}
|
||||
file={f}
|
||||
onDelete={() => deleteFile(f.id, status.id)}
|
||||
deleting={deletingId === f.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
|
||||
// ── Colors ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const BASE_COLORS = {
|
||||
Success: { bg: 'bg-green-100', border: 'border-green-300', text: 'text-green-800', dot: 'bg-green-500', badge: 'bg-green-500' },
|
||||
Failure: { bg: 'bg-red-100', border: 'border-red-300', text: 'text-red-800', dot: 'bg-red-500', badge: 'bg-red-500' },
|
||||
Other: { bg: 'bg-gray-100', border: 'border-gray-300', text: 'text-gray-700', dot: 'bg-gray-400', badge: 'bg-gray-400' },
|
||||
};
|
||||
const EXTRA_COLORS = [
|
||||
{ bg: 'bg-indigo-100', border: 'border-indigo-300', text: 'text-indigo-800', dot: 'bg-indigo-500', badge: 'bg-indigo-500' },
|
||||
{ bg: 'bg-yellow-100', border: 'border-yellow-300', text: 'text-yellow-800', dot: 'bg-yellow-500', badge: 'bg-yellow-500' },
|
||||
{ bg: 'bg-purple-100', border: 'border-purple-300', text: 'text-purple-800', dot: 'bg-purple-500', badge: 'bg-purple-500' },
|
||||
{ bg: 'bg-pink-100', border: 'border-pink-300', text: 'text-pink-800', dot: 'bg-pink-500', badge: 'bg-pink-500' },
|
||||
];
|
||||
function getColors(cat, customCats = []) {
|
||||
if (BASE_COLORS[cat]) return BASE_COLORS[cat];
|
||||
return EXTRA_COLORS[Math.max(0, customCats.indexOf(cat)) % EXTRA_COLORS.length];
|
||||
}
|
||||
|
||||
const ORDINALS = ['1st', '2nd', '3rd', '4th', '5th'];
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Build display items from a sequence slice, respecting mode + collapsibility. */
|
||||
function buildItems(seqSlice, mode, collapsible) {
|
||||
const categorised = seqSlice.filter(s => s.category);
|
||||
if (mode === 'discrete') {
|
||||
return categorised.map((s, i) => ({ id: i, category: s.category, count: 1 }));
|
||||
}
|
||||
// Rebuild runs within this slice, then apply collapsibility
|
||||
const runs = [];
|
||||
for (const s of categorised) {
|
||||
if (runs.length === 0 || runs[runs.length - 1].category !== s.category) {
|
||||
runs.push({ category: s.category, length: 1 });
|
||||
} else {
|
||||
runs[runs.length - 1].length++;
|
||||
}
|
||||
}
|
||||
const items = [];
|
||||
for (const run of runs) {
|
||||
if (collapsible[run.category] === false) {
|
||||
for (let i = 0; i < run.length; i++) items.push({ id: items.length, category: run.category, count: 1 });
|
||||
} else {
|
||||
items.push({ id: items.length, category: run.category, count: run.length });
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Compute per-category counts and total for a sequence slice. */
|
||||
function segStats(seqSlice) {
|
||||
const byCat = {};
|
||||
for (const s of seqSlice) {
|
||||
if (!s.category) continue;
|
||||
byCat[s.category] = (byCat[s.category] || 0) + 1;
|
||||
}
|
||||
const total = Object.values(byCat).reduce((a, b) => a + b, 0);
|
||||
return { byCat, total };
|
||||
}
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Props:
|
||||
* runs {category, length}[] — global run-length-encoded sequence
|
||||
* sequence {note, category, timestamp}[]
|
||||
* customCategories string[]
|
||||
*/
|
||||
export default function RunSequenceView({ runs = [], sequence = [], customCategories = [], sessionEndTs = null }) {
|
||||
const [mode, setMode] = useState('grouped');
|
||||
const [collapsible, setCollapsible] = useState({});
|
||||
const [splitN, setSplitN] = useState(1);
|
||||
const [durationMins, setDurationMins] = useState(20);
|
||||
const [selectedSegment, setSelectedSegment] = useState(null);
|
||||
|
||||
// Initialise collapsibility for any new categories
|
||||
const allCats = useMemo(() => [...new Set(runs.map(r => r.category))], [runs]);
|
||||
useEffect(() => {
|
||||
setCollapsible(prev => {
|
||||
const next = { ...prev };
|
||||
for (const cat of allCats) {
|
||||
if (!(cat in next)) next[cat] = cat !== 'Success';
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [allCats]);
|
||||
|
||||
// Reset selected segment when split changes
|
||||
useEffect(() => { setSelectedSegment(null); }, [splitN]);
|
||||
|
||||
// ── Timestamp bounds ─────────────────────────────────────────────────────────
|
||||
|
||||
const { startTs, segSize, windowUnderrun } = useMemo(() => {
|
||||
let endT = sessionEndTs != null ? sessionEndTs : null;
|
||||
if (endT === null) {
|
||||
const ts = sequence.map(s => parseFloat(s.timestamp)).filter(t => !isNaN(t));
|
||||
endT = ts.length ? Math.max(...ts) : durationMins * 60;
|
||||
}
|
||||
const rawStart = endT - durationMins * 60;
|
||||
return {
|
||||
startTs: rawStart,
|
||||
segSize: (durationMins * 60) / Math.max(splitN, 1),
|
||||
windowUnderrun: rawStart < 0 ? Math.abs(rawStart) : 0,
|
||||
};
|
||||
}, [sequence, sessionEndTs, durationMins, splitN]);
|
||||
|
||||
function segIndexOf(timestamp) {
|
||||
const t = parseFloat(timestamp);
|
||||
if (isNaN(t)) return 0;
|
||||
return Math.min(Math.max(Math.floor((t - startTs) / segSize), 0), splitN - 1);
|
||||
}
|
||||
|
||||
// ── Segment data (only when split > 1) ───────────────────────────────────────
|
||||
|
||||
const segmentData = useMemo(() => {
|
||||
if (splitN === 1) return null;
|
||||
const groups = Array.from({ length: splitN }, () => []);
|
||||
for (const s of sequence) {
|
||||
groups[segIndexOf(s.timestamp)].push(s);
|
||||
}
|
||||
return groups.map((grp, i) => ({
|
||||
index: i,
|
||||
items: buildItems(grp, mode, collapsible),
|
||||
stats: segStats(grp),
|
||||
}));
|
||||
}, [splitN, sequence, mode, collapsible, startTs, segSize]);
|
||||
|
||||
// ── Single-segment display items (split = 1) ─────────────────────────────────
|
||||
|
||||
const singleItems = useMemo(() => {
|
||||
if (splitN !== 1) return [];
|
||||
if (mode === 'discrete') {
|
||||
const src = sequence.length > 0
|
||||
? sequence.filter(s => s.category)
|
||||
: runs.flatMap(r => Array.from({ length: r.length }, () => ({ category: r.category })));
|
||||
return src.map((s, i) => ({ id: i, category: s.category, count: 1 }));
|
||||
}
|
||||
const items = [];
|
||||
for (const run of runs) {
|
||||
if (collapsible[run.category] === false) {
|
||||
for (let i = 0; i < run.length; i++) items.push({ id: items.length, category: run.category, count: 1 });
|
||||
} else {
|
||||
items.push({ id: items.length, category: run.category, count: run.length });
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}, [splitN, mode, runs, sequence, collapsible]);
|
||||
|
||||
// ── Global totals ─────────────────────────────────────────────────────────────
|
||||
|
||||
const globalStats = useMemo(() => segStats(sequence), [sequence]);
|
||||
|
||||
if (runs.length === 0) return null;
|
||||
|
||||
const hasTimestamps = sequence.some(s => !isNaN(parseFloat(s.timestamp)));
|
||||
const activeSegStats = selectedSegment !== null && segmentData ? segmentData[selectedSegment]?.stats ?? null : null;
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* ── Controls row ── */}
|
||||
<div className="flex items-center flex-wrap gap-x-4 gap-y-1.5 mb-2">
|
||||
{/* Title */}
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Run sequence ({runs.length} runs)
|
||||
</p>
|
||||
|
||||
{/* Mode toggle */}
|
||||
<div className="flex rounded border border-gray-200 overflow-hidden text-xs">
|
||||
<button type="button" onClick={() => setMode('discrete')}
|
||||
className={`px-2 py-0.5 transition-colors ${mode === 'discrete' ? 'bg-gray-700 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
|
||||
Discrete
|
||||
</button>
|
||||
<button type="button" onClick={() => setMode('grouped')}
|
||||
className={`px-2 py-0.5 border-l border-gray-200 transition-colors ${mode === 'grouped' ? 'bg-gray-700 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
|
||||
Grouped
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Time split */}
|
||||
{hasTimestamps && (
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-gray-400">Split:</span>
|
||||
<select value={splitN} onChange={e => setSplitN(Number(e.target.value))}
|
||||
className="border border-gray-200 rounded px-1.5 py-0.5 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400">
|
||||
<option value={1}>None</option>
|
||||
<option value={2}>½</option>
|
||||
<option value={3}>⅓</option>
|
||||
<option value={4}>¼</option>
|
||||
<option value={5}>⅕</option>
|
||||
</select>
|
||||
<input
|
||||
type="number" min={1} max={600} value={durationMins}
|
||||
onChange={e => setDurationMins(Math.max(1, Number(e.target.value)))}
|
||||
className="border border-gray-200 rounded px-1.5 py-0.5 w-14 text-xs text-right focus:outline-none focus:ring-1 focus:ring-indigo-400"
|
||||
title="Experiment duration in minutes"
|
||||
/>
|
||||
<span className="text-gray-400">min</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Per-category collapse toggles (grouped mode) ── */}
|
||||
{mode === 'grouped' && allCats.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{allCats.map(cat => {
|
||||
const c = getColors(cat, customCategories);
|
||||
const isGrouped = collapsible[cat] !== false;
|
||||
return (
|
||||
<button key={cat} type="button"
|
||||
onClick={() => setCollapsible(prev => ({ ...prev, [cat]: !prev[cat] }))}
|
||||
title={isGrouped ? 'Grouped — click for discrete' : 'Discrete — click to group'}
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs border transition-colors
|
||||
${isGrouped ? `${c.bg} ${c.border} ${c.text}` : `bg-white ${c.border} ${c.text} opacity-70`}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${c.dot}`} />
|
||||
{cat}: {isGrouped ? 'grouped' : 'discrete'}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Underrun warning ── */}
|
||||
{splitN > 1 && windowUnderrun > 0 && (() => {
|
||||
const u = Math.round(windowUnderrun);
|
||||
const m = Math.floor(u / 60), s = u % 60;
|
||||
return (
|
||||
<p className="text-[10px] text-amber-600 bg-amber-50 border border-amber-200 rounded px-2 py-1 mb-2">
|
||||
The {durationMins}-min window starts {m}:{String(s).padStart(2,'0')} before the recording — the 1st segment includes unrecorded time.
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* ── Segment buttons (when split > 1) ── */}
|
||||
{splitN > 1 && segmentData && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{segmentData.map((seg, si) => {
|
||||
const isActive = selectedSegment === si;
|
||||
const segStart = startTs + si * segSize;
|
||||
const segEnd = startTs + (si + 1) * segSize;
|
||||
const fmt = s => {
|
||||
const t = Math.round(s);
|
||||
const clamped = Math.max(0, t);
|
||||
const m = Math.floor(clamped / 60);
|
||||
const sec = clamped % 60;
|
||||
const prefix = t < 0 ? '0:00*' : `${m}:${String(sec).padStart(2, '0')}`;
|
||||
return prefix;
|
||||
};
|
||||
return (
|
||||
<button key={si} type="button"
|
||||
onClick={() => setSelectedSegment(prev => prev === si ? null : si)}
|
||||
title={`${fmt(segStart)} – ${fmt(segEnd)}`}
|
||||
className={`inline-flex flex-col items-center px-2.5 py-1 rounded-lg text-xs font-medium border transition-all
|
||||
${isActive ? 'bg-gray-800 text-white border-gray-800' : 'bg-white text-gray-600 border-gray-300 hover:border-gray-400'}`}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{ORDINALS[si]}
|
||||
<span className={`text-[10px] px-1 py-px rounded-full font-semibold
|
||||
${isActive ? 'bg-white/20 text-white' : 'bg-gray-100 text-gray-500'}`}>
|
||||
{seg.stats.total}
|
||||
</span>
|
||||
</span>
|
||||
<span className={`text-[9px] font-normal tabular-nums ${isActive ? 'text-gray-300' : 'text-gray-400'}`}>
|
||||
{fmt(segStart)}–{fmt(segEnd)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{selectedSegment !== null && (
|
||||
<button type="button" onClick={() => setSelectedSegment(null)}
|
||||
className="text-xs text-gray-400 hover:text-gray-600 px-1">✕</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Selected segment stats ── */}
|
||||
{activeSegStats && (
|
||||
<div className="flex flex-wrap items-center gap-3 mb-2 px-3 py-2 bg-gray-50 rounded-lg border border-gray-200 text-xs">
|
||||
<span className="font-semibold text-gray-700">{ORDINALS[selectedSegment]} segment</span>
|
||||
<span className="text-gray-500">{activeSegStats.total} attempt{activeSegStats.total !== 1 ? 's' : ''}</span>
|
||||
{Object.entries(activeSegStats.byCat).sort(([, a], [, b]) => b - a).map(([cat, n]) => {
|
||||
const c = getColors(cat, customCategories);
|
||||
return (
|
||||
<span key={cat} className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full ${c.bg} ${c.text}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${c.dot}`} />
|
||||
{cat}: {n}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Chip area ── */}
|
||||
<div className="flex flex-wrap gap-1 items-center">
|
||||
{splitN === 1
|
||||
? singleItems.map(item => <Chip key={item.id} item={item} customCategories={customCategories} dimmed={false} />)
|
||||
: segmentData && segmentData.map((seg, si) => (
|
||||
<React.Fragment key={si}>
|
||||
{si > 0 && (
|
||||
<div className="flex flex-col items-center mx-0.5 self-stretch" aria-hidden>
|
||||
<div className="w-px flex-1 bg-gray-300" style={{ minHeight: 20 }} />
|
||||
</div>
|
||||
)}
|
||||
{seg.items.length === 0
|
||||
? <span className="text-[10px] text-gray-300 italic px-1">empty</span>
|
||||
: seg.items.map(item => (
|
||||
<Chip key={`${si}-${item.id}`} item={item} customCategories={customCategories}
|
||||
dimmed={selectedSegment !== null && selectedSegment !== si} />
|
||||
))}
|
||||
</React.Fragment>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* ── Global totals ── */}
|
||||
<div className="flex flex-wrap items-center gap-3 mt-2 pt-2 border-t border-gray-100 text-xs text-gray-500">
|
||||
<span className="font-medium text-gray-700">Total: {globalStats.total} attempt{globalStats.total !== 1 ? 's' : ''}</span>
|
||||
{Object.entries(globalStats.byCat).sort(([, a], [, b]) => b - a).map(([cat, n]) => {
|
||||
const c = getColors(cat, customCategories);
|
||||
return (
|
||||
<span key={cat} className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full ${c.bg} ${c.text}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${c.dot}`} />
|
||||
{cat}: {n}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Chip({ item, customCategories, dimmed }) {
|
||||
const c = getColors(item.category, customCategories);
|
||||
return (
|
||||
<span
|
||||
title={item.count > 1 ? `${item.category} × ${item.count}` : item.category}
|
||||
className={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono border transition-opacity
|
||||
${c.bg} ${c.text} ${c.border} ${dimmed ? 'opacity-25' : 'opacity-100'}`}>
|
||||
{item.count > 1 ? `${item.count}×` : ''}{item.category.slice(0, 3)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Button from './ui/Button';
|
||||
import { Input, Textarea } from './ui/FormField';
|
||||
|
||||
function widthToSpan(pct) {
|
||||
if (pct <= 8) return 1;
|
||||
if (pct <= 17) return 2;
|
||||
if (pct <= 25) return 3;
|
||||
if (pct <= 33) return 4;
|
||||
if (pct <= 42) return 5;
|
||||
if (pct <= 50) return 6;
|
||||
if (pct <= 67) return 8;
|
||||
if (pct <= 75) return 9;
|
||||
return 12;
|
||||
}
|
||||
import Alert from './ui/Alert';
|
||||
import { animalsApi, experimentsApi } from '../api/client';
|
||||
|
||||
export default function SubjectInfoForm({ animal, subjectTemplate = [], experimentId, onTemplateUpdate, onSaved, onCancel }) {
|
||||
const activeFields = subjectTemplate.filter((f) => f.active);
|
||||
|
||||
function buildInitialValues() {
|
||||
const vals = {};
|
||||
for (const f of activeFields) {
|
||||
vals[f.key] = animal?.subject_info?.[f.fieldId] ?? '';
|
||||
}
|
||||
return vals;
|
||||
}
|
||||
|
||||
const [values, setValues] = useState(buildInitialValues);
|
||||
const [apiError, setApiError] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [savingTagFor, setSavingTagFor] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
setValues(buildInitialValues());
|
||||
setApiError(null);
|
||||
}, [animal, subjectTemplate]);
|
||||
|
||||
const set = (key) => (e) => setValues((v) => ({ ...v, [key]: e.target.value }));
|
||||
|
||||
async function saveTag(field) {
|
||||
const value = (values[field.key] ?? '').trim();
|
||||
if (!value || !experimentId || !onTemplateUpdate) return;
|
||||
setSavingTagFor(field.fieldId);
|
||||
try {
|
||||
const updatedTemplate = subjectTemplate.map((f) =>
|
||||
f.fieldId === field.fieldId
|
||||
? { ...f, tags: [...(f.tags ?? []), value] }
|
||||
: f
|
||||
);
|
||||
await experimentsApi.updateSubjectTemplate(experimentId, updatedTemplate);
|
||||
onTemplateUpdate(updatedTemplate);
|
||||
} catch (_) {
|
||||
} finally {
|
||||
setSavingTagFor(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setApiError(null);
|
||||
try {
|
||||
// Preserve any fields not in the current active template
|
||||
const existing = animal?.subject_info ?? {};
|
||||
const updates = {};
|
||||
for (const f of activeFields) {
|
||||
updates[f.fieldId] = values[f.key] || null;
|
||||
}
|
||||
const merged = { ...existing, ...updates };
|
||||
|
||||
const updated = await animalsApi.update(animal.id, { subject_info: merged });
|
||||
onSaved(updated);
|
||||
} catch (err) {
|
||||
setApiError(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (activeFields.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-6">
|
||||
<p className="text-sm text-gray-400 italic">No subject info fields configured for this experiment.</p>
|
||||
<p className="text-xs text-gray-400 mt-1">Use "Subject Template" on the experiment page to add fields.</p>
|
||||
<div className="flex justify-end mt-4">
|
||||
<Button variant="secondary" type="button" onClick={onCancel}>Close</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
{apiError && (
|
||||
<Alert type="error" message={apiError.message} details={apiError.details} onDismiss={() => setApiError(null)} />
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-12 gap-x-3 gap-y-2 mb-4">
|
||||
{activeFields.map((field) => {
|
||||
const isTextarea = field.type === 'textarea';
|
||||
const span = isTextarea ? 12 : widthToSpan(field.width ?? 100);
|
||||
const fieldTags = field.tags ?? [];
|
||||
const currentValue = (values[field.key] ?? '').trim();
|
||||
const canSaveTag = experimentId && onTemplateUpdate && currentValue && !fieldTags.includes(currentValue) && !field.tagsDisabled;
|
||||
return (
|
||||
<div key={field.fieldId} className={`col-span-${span} flex items-start gap-2`}>
|
||||
<label className="text-xs font-medium text-gray-500 whitespace-nowrap w-28 shrink-0 text-right pt-1.5">{field.label}</label>
|
||||
<div className="flex-1 min-w-0">
|
||||
{isTextarea ? (
|
||||
<Textarea name={field.key} value={values[field.key] ?? ''} onChange={set(field.key)} disabled={loading} />
|
||||
) : (
|
||||
<Input name={field.key} value={values[field.key] ?? ''} onChange={set(field.key)} disabled={loading} />
|
||||
)}
|
||||
{(fieldTags.length > 0 || canSaveTag) && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 mt-1">
|
||||
{fieldTags.map((tag) => (
|
||||
<button key={tag} type="button" title={`Quick-fill: ${tag}`}
|
||||
onClick={() => setValues((v) => ({ ...v, [field.key]: tag }))}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded-full bg-indigo-50 border border-indigo-200 text-indigo-700 text-xs font-medium hover:bg-indigo-100 transition-colors">
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
{canSaveTag && (
|
||||
<button type="button" onClick={() => saveTag(field)} disabled={savingTagFor === field.fieldId}
|
||||
title="Save current value as a quick-fill tag"
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-dashed border-indigo-300 text-indigo-500 text-xs hover:border-indigo-500 hover:text-indigo-700 transition-colors disabled:opacity-50">
|
||||
{savingTagFor === field.fieldId ? '…' : '+ Save as tag'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2 border-t border-gray-100">
|
||||
<Button variant="secondary" type="button" onClick={onCancel} disabled={loading}>Cancel</Button>
|
||||
<Button type="submit" loading={loading}>Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { groupLines, groupVisibilityState } from '../lib/crossSubjectChart';
|
||||
|
||||
// Presentational panel listing subjects grouped by their group, with per-subject
|
||||
// and per-group visibility checkboxes. All state lives in the parent; this component
|
||||
// only renders and calls handlers.
|
||||
export function SubjectSidebar({
|
||||
lines, hiddenSubjects, onToggleSubject, onToggleGroup, onShowAll, onHighlight,
|
||||
}) {
|
||||
const groups = groupLines(lines);
|
||||
const anyHidden = lines.some((l) => hiddenSubjects.has(l.id));
|
||||
|
||||
return (
|
||||
<div className="w-[210px] shrink-0 border-l border-gray-100 pl-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Subjects</span>
|
||||
{anyHidden && (
|
||||
<button type="button" onClick={onShowAll} aria-label="Show all subjects"
|
||||
className="text-xs text-indigo-500 hover:text-indigo-700">Show all</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3 max-h-[320px] overflow-y-auto pr-1">
|
||||
{groups.map(({ group, subjects }) => {
|
||||
const ids = subjects.map((s) => s.id);
|
||||
const state = groupVisibilityState(ids, hiddenSubjects);
|
||||
return (
|
||||
<div key={group}>
|
||||
<label className="flex items-center gap-1.5 text-xs font-medium text-gray-600 cursor-pointer">
|
||||
<input type="checkbox"
|
||||
checked={state === 'all'}
|
||||
ref={(el) => { if (el) el.indeterminate = state === 'some'; }}
|
||||
onChange={() => onToggleGroup(ids, state === 'all')}
|
||||
className="accent-indigo-500" />
|
||||
<span className="truncate">{group}</span>
|
||||
<span className="text-gray-400">({subjects.length})</span>
|
||||
</label>
|
||||
<div className="mt-1 space-y-0.5 pl-1">
|
||||
{subjects.map((s) => (
|
||||
<label key={s.id}
|
||||
onMouseEnter={() => onHighlight(s.id)}
|
||||
onMouseLeave={() => onHighlight(null)}
|
||||
className="flex items-center gap-1.5 text-xs text-gray-600 cursor-pointer hover:bg-gray-50 rounded px-1 py-0.5">
|
||||
<input type="checkbox"
|
||||
checked={!hiddenSubjects.has(s.id)}
|
||||
onChange={() => onToggleSubject(s.id)}
|
||||
className="accent-indigo-500" />
|
||||
<span className="inline-block w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: s.color }} />
|
||||
<span className="truncate">{s.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { experimentsApi } from '../api/client';
|
||||
import Button from './ui/Button';
|
||||
import Alert from './ui/Alert';
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function toKey(label) {
|
||||
return label.toLowerCase().trim()
|
||||
.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 64);
|
||||
}
|
||||
|
||||
// ── Tag chip ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function TagChip({ value, onRemove }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-indigo-50 border border-indigo-200 text-indigo-700 text-xs font-medium">
|
||||
{value}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove tag ${value}`}
|
||||
onClick={onRemove}
|
||||
className="text-indigo-400 hover:text-indigo-700 leading-none"
|
||||
>✕</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Inline tag editor (expanded panel below a field row) ──────────────────────
|
||||
|
||||
function TagPanel({ field, onChange, onAddTag, onRemoveTag }) {
|
||||
const [draft, setDraft] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
const disabled = !!field.tagsDisabled;
|
||||
|
||||
function commit() {
|
||||
const v = draft.trim();
|
||||
if (!v) { setErr('Tag cannot be empty'); return; }
|
||||
if (v.length > 512) { setErr('Tag must be ≤512 characters'); return; }
|
||||
if ((field.tags ?? []).includes(v)) { setErr('Tag already exists'); return; }
|
||||
setErr('');
|
||||
onAddTag(field.fieldId, v);
|
||||
setDraft('');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-3 pt-2 bg-indigo-50/40 border-t border-indigo-100 rounded-b-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-xs font-semibold text-indigo-600 uppercase tracking-wide">Quick-fill tags</p>
|
||||
<label className="flex items-center gap-1.5 text-xs text-gray-500 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={disabled}
|
||||
onChange={(e) => onChange({ ...field, tagsDisabled: e.target.checked })}
|
||||
className="w-3.5 h-3.5 accent-indigo-600"
|
||||
/>
|
||||
Disable tagging
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!disabled && (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-1.5 mb-2 min-h-[24px]">
|
||||
{(field.tags ?? []).length === 0 && (
|
||||
<span className="text-xs text-gray-400 italic">No tags yet.</span>
|
||||
)}
|
||||
{(field.tags ?? []).map((t) => (
|
||||
<TagChip key={t} value={t} onRemove={() => onRemoveTag(field.fieldId, t)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-start">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
aria-label={`Add tag for ${field.label}`}
|
||||
className={`w-full text-sm rounded border px-2 py-1 focus:outline-none focus:ring-2 focus:ring-indigo-400 ${err ? 'border-red-400' : 'border-gray-300'}`}
|
||||
placeholder="Type a tag value…"
|
||||
value={draft}
|
||||
onChange={(e) => { setDraft(e.target.value); setErr(''); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commit(); } }}
|
||||
/>
|
||||
{err && <p className="text-xs text-red-500 mt-0.5">{err}</p>}
|
||||
</div>
|
||||
<Button size="sm" type="button" onClick={commit}>+ Add</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{disabled && (
|
||||
<p className="text-xs text-gray-400 italic">Tagging is disabled for this field. Existing tags are preserved.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Single field row (draggable) ───────────────────────────────────────────────
|
||||
|
||||
function FieldRow({
|
||||
field, index, total,
|
||||
onChange, onToggle, onRemove, onAddTag, onRemoveTag,
|
||||
allKeys,
|
||||
// drag props
|
||||
onDragStart, onDragEnter, onDragEnd,
|
||||
isDragOver,
|
||||
}) {
|
||||
const [labelDraft, setLabelDraft] = useState(field.label);
|
||||
const [labelError, setLabelError] = useState('');
|
||||
const [showTags, setShowTags] = useState(false);
|
||||
|
||||
useEffect(() => { setLabelDraft(field.label); }, [field.label]);
|
||||
|
||||
function commitLabel() {
|
||||
const trimmed = labelDraft.trim();
|
||||
if (!trimmed) { setLabelError('Label cannot be empty'); return; }
|
||||
if (trimmed.length > 128) { setLabelError('Label must be ≤128 characters'); return; }
|
||||
if (!field.builtin) {
|
||||
const newKey = toKey(trimmed);
|
||||
if (!newKey) { setLabelError('Label must contain at least one alphanumeric character'); return; }
|
||||
if (allKeys.find((k) => k !== field.key && k === newKey)) {
|
||||
setLabelError('A field with this name already exists'); return;
|
||||
}
|
||||
}
|
||||
setLabelError('');
|
||||
onChange({ ...field, label: trimmed, key: field.builtin ? field.key : toKey(trimmed) });
|
||||
}
|
||||
|
||||
const tagCount = (field.tags ?? []).length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border transition-all ${isDragOver ? 'border-blue-400 shadow-md scale-[1.01]' : 'border-gray-200'} ${field.active ? 'bg-white' : 'bg-gray-50 opacity-60'}`}
|
||||
draggable
|
||||
onDragStart={() => onDragStart(index)}
|
||||
onDragEnter={() => onDragEnter(index)}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
>
|
||||
{/* Main row */}
|
||||
<div className="flex items-center gap-3 px-3 py-2.5">
|
||||
{/* Drag handle */}
|
||||
<span
|
||||
className="text-gray-300 hover:text-gray-500 cursor-grab active:cursor-grabbing select-none text-lg leading-none"
|
||||
title="Drag to reorder"
|
||||
aria-label="Drag handle"
|
||||
>⠿</span>
|
||||
|
||||
{/* Label input */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<input
|
||||
aria-label={`Field label for ${field.key}`}
|
||||
className={`w-full text-sm rounded border px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500 ${labelError ? 'border-red-400' : 'border-gray-300'} ${!field.active ? 'bg-gray-100' : 'bg-white'}`}
|
||||
value={labelDraft}
|
||||
disabled={!field.active}
|
||||
onChange={(e) => { setLabelDraft(e.target.value); setLabelError(''); }}
|
||||
onBlur={commitLabel}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitLabel(); } }}
|
||||
/>
|
||||
{labelError && <p className="text-xs text-red-500 mt-0.5">{labelError}</p>}
|
||||
<p className="text-xs text-gray-400 mt-0.5 font-mono">
|
||||
key: {field.key}
|
||||
{field.fieldId && (
|
||||
<span className="ml-2 text-gray-300" title={`Stable field ID: ${field.fieldId}`}>
|
||||
· id: {field.fieldId.slice(0, 8)}…
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Type selector */}
|
||||
<select
|
||||
aria-label={`Field type for ${field.key}`}
|
||||
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-blue-400 disabled:bg-gray-100"
|
||||
value={field.type}
|
||||
disabled={!field.active}
|
||||
onChange={(e) => onChange({ ...field, type: e.target.value })}
|
||||
>
|
||||
<option value="text">Short text</option>
|
||||
<option value="textarea">Long text</option>
|
||||
</select>
|
||||
|
||||
{/* Width (text fields only) */}
|
||||
{field.type === 'text' && (
|
||||
<select
|
||||
aria-label={`Width for ${field.key}`}
|
||||
title="Input width in the entry form"
|
||||
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-blue-400 disabled:bg-gray-100"
|
||||
value={field.width ?? 100}
|
||||
disabled={!field.active}
|
||||
onChange={(e) => onChange({ ...field, width: Number(e.target.value) })}
|
||||
>
|
||||
<option value={100}>Full</option>
|
||||
<option value={75}>3/4</option>
|
||||
<option value={67}>2/3</option>
|
||||
<option value={50}>1/2</option>
|
||||
<option value={42}>5/12</option>
|
||||
<option value={33}>1/3</option>
|
||||
<option value={25}>1/4</option>
|
||||
<option value={17}>1/6</option>
|
||||
<option value={8}>1/12</option>
|
||||
</select>
|
||||
)}
|
||||
|
||||
{/* Abbreviation */}
|
||||
<input
|
||||
aria-label={`Abbreviation for ${field.key}`}
|
||||
className="text-xs border border-gray-200 rounded px-2 py-1 w-20 focus:outline-none focus:ring-1 focus:ring-blue-400 disabled:bg-gray-100"
|
||||
placeholder="Abbr"
|
||||
title="Column abbreviation (≤10 chars)"
|
||||
maxLength={10}
|
||||
value={field.abbr ?? ''}
|
||||
disabled={!field.active}
|
||||
onChange={(e) => onChange({ ...field, abbr: e.target.value })}
|
||||
/>
|
||||
|
||||
{/* Unit */}
|
||||
<input
|
||||
aria-label={`Unit for ${field.key}`}
|
||||
className="text-xs border border-gray-200 rounded px-2 py-1 w-20 focus:outline-none focus:ring-1 focus:ring-blue-400 disabled:bg-gray-100"
|
||||
placeholder="Unit"
|
||||
title="Measurement unit (≤10 chars)"
|
||||
maxLength={10}
|
||||
value={field.unit ?? ''}
|
||||
disabled={!field.active}
|
||||
onChange={(e) => onChange({ ...field, unit: e.target.value })}
|
||||
/>
|
||||
|
||||
{/* Tags toggle */}
|
||||
<button
|
||||
type="button"
|
||||
title={showTags ? 'Hide tags' : 'Manage quick-fill tags'}
|
||||
aria-label={`Manage tags for ${field.label}`}
|
||||
onClick={() => setShowTags((v) => !v)}
|
||||
className={`relative text-sm px-2 py-1 rounded border transition-colors ${
|
||||
showTags
|
||||
? 'border-indigo-400 bg-indigo-50 text-indigo-700'
|
||||
: 'border-gray-200 text-gray-500 hover:bg-indigo-50 hover:border-indigo-300 hover:text-indigo-700'
|
||||
}`}
|
||||
>
|
||||
🏷
|
||||
{tagCount > 0 && (
|
||||
<span className="absolute -top-1.5 -right-1.5 bg-indigo-600 text-white text-[10px] font-bold rounded-full w-4 h-4 flex items-center justify-center leading-none">
|
||||
{tagCount > 9 ? '9+' : tagCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Hide / Show */}
|
||||
<button
|
||||
type="button"
|
||||
title={field.active ? 'Hide field' : 'Show field'}
|
||||
aria-label={field.active ? `Hide ${field.label}` : `Show ${field.label}`}
|
||||
onClick={() => onToggle(field.fieldId)}
|
||||
className={`text-sm px-2 py-1 rounded border transition-colors ${
|
||||
field.active
|
||||
? 'border-gray-200 text-gray-500 hover:bg-yellow-50 hover:border-yellow-300 hover:text-yellow-700'
|
||||
: 'border-green-200 text-green-600 hover:bg-green-50'
|
||||
}`}
|
||||
>
|
||||
{field.active ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
|
||||
{/* Remove (custom fields only) */}
|
||||
{!field.builtin && (
|
||||
<button
|
||||
type="button"
|
||||
title="Remove field permanently"
|
||||
aria-label={`Remove ${field.label}`}
|
||||
onClick={() => onRemove(field.fieldId)}
|
||||
className="text-sm px-2 py-1 rounded border border-red-200 text-red-500 hover:bg-red-50 transition-colors"
|
||||
>✕</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tag panel (collapsible) */}
|
||||
{showTags && (
|
||||
<TagPanel field={field} onChange={onChange} onAddTag={onAddTag} onRemoveTag={onRemoveTag} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main TemplateEditor ────────────────────────────────────────────────────────
|
||||
|
||||
export default function TemplateEditor({ experimentId, onClose, loadFn, saveFn }) {
|
||||
const [fields, setFields] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
// Add-field form
|
||||
const [newLabel, setNewLabel] = useState('');
|
||||
const [newType, setNewType] = useState('text');
|
||||
const [addError, setAddError] = useState('');
|
||||
|
||||
// Drag state
|
||||
const dragIndex = useRef(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fn = loadFn ?? (() => experimentsApi.getTemplate(experimentId));
|
||||
fn().then(setFields).catch((e) => setError(e.message)).finally(() => setLoading(false));
|
||||
}, [experimentId]);
|
||||
|
||||
// ── field mutation helpers ─────────────────────────────────────────────────
|
||||
|
||||
function updateField(updated) {
|
||||
setFields((prev) => prev.map((f) => f.fieldId === updated.fieldId ? updated : f));
|
||||
}
|
||||
function toggleField(fieldId) {
|
||||
setFields((prev) => prev.map((f) => f.fieldId === fieldId ? { ...f, active: !f.active } : f));
|
||||
}
|
||||
function removeField(fieldId) {
|
||||
setFields((prev) => prev.filter((f) => f.fieldId !== fieldId));
|
||||
}
|
||||
|
||||
// ── tag helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function addTag(fieldId, value) {
|
||||
setFields((prev) => prev.map((f) =>
|
||||
f.fieldId === fieldId
|
||||
? { ...f, tags: [...(f.tags ?? []), value] }
|
||||
: f
|
||||
));
|
||||
}
|
||||
function removeTag(fieldId, value) {
|
||||
setFields((prev) => prev.map((f) =>
|
||||
f.fieldId === fieldId
|
||||
? { ...f, tags: (f.tags ?? []).filter((t) => t !== value) }
|
||||
: f
|
||||
));
|
||||
}
|
||||
|
||||
// ── drag handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
function handleDragStart(index) {
|
||||
dragIndex.current = index;
|
||||
}
|
||||
function handleDragEnter(index) {
|
||||
setDragOverIndex(index);
|
||||
}
|
||||
function handleDragEnd() {
|
||||
const from = dragIndex.current;
|
||||
const to = dragOverIndex;
|
||||
if (from !== null && to !== null && from !== to) {
|
||||
setFields((prev) => {
|
||||
const next = [...prev];
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(to, 0, moved);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
dragIndex.current = null;
|
||||
setDragOverIndex(null);
|
||||
}
|
||||
|
||||
// ── add new field ──────────────────────────────────────────────────────────
|
||||
|
||||
function addField() {
|
||||
const trimmed = newLabel.trim();
|
||||
if (!trimmed) { setAddError('Field name is required'); return; }
|
||||
const key = toKey(trimmed);
|
||||
if (!key) { setAddError('Name must contain at least one alphanumeric character'); return; }
|
||||
if (fields.some((f) => f.key === key)) { setAddError(`A field with key "${key}" already exists`); return; }
|
||||
setAddError('');
|
||||
const fieldId = crypto.randomUUID();
|
||||
setFields((prev) => [...prev, { fieldId, key, label: trimmed, type: newType, builtin: false, active: true, tags: [] }]);
|
||||
setNewLabel('');
|
||||
setNewType('text');
|
||||
}
|
||||
|
||||
// ── save ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const fn = saveFn ?? ((f) => experimentsApi.updateTemplate(experimentId, f));
|
||||
await fn(fields);
|
||||
setSuccess(true);
|
||||
setTimeout(() => { setSuccess(false); onClose(fields); }, 700);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const allKeys = fields.map((f) => f.key);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{loading && <p className="text-sm text-gray-400">Loading template…</p>}
|
||||
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
|
||||
{success && <Alert type="success" message="Template saved!" />}
|
||||
|
||||
{!loading && (
|
||||
<>
|
||||
{/* Field list */}
|
||||
<div className="space-y-2">
|
||||
{fields.length === 0 && (
|
||||
<p className="text-sm text-gray-400 italic text-center py-4">No fields yet. Add one below.</p>
|
||||
)}
|
||||
{fields.map((field, index) => (
|
||||
<FieldRow
|
||||
key={field.fieldId}
|
||||
field={field}
|
||||
index={index}
|
||||
total={fields.length}
|
||||
onChange={updateField}
|
||||
onToggle={toggleField}
|
||||
onRemove={removeField}
|
||||
onAddTag={addTag}
|
||||
onRemoveTag={removeTag}
|
||||
allKeys={allKeys}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragEnd={handleDragEnd}
|
||||
isDragOver={dragOverIndex === index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add new field */}
|
||||
<div className="border-t pt-4">
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">Add New Field</p>
|
||||
<div className="flex gap-2 items-start">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
aria-label="New field name"
|
||||
className={`w-full text-sm rounded border px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 ${addError ? 'border-red-400' : 'border-gray-300'}`}
|
||||
placeholder="Field name, e.g. Blood Pressure"
|
||||
value={newLabel}
|
||||
onChange={(e) => { setNewLabel(e.target.value); setAddError(''); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addField(); } }}
|
||||
/>
|
||||
{addError && <p className="text-xs text-red-500 mt-0.5">{addError}</p>}
|
||||
</div>
|
||||
<select
|
||||
aria-label="New field type"
|
||||
className="text-sm border border-gray-300 rounded px-2 py-2 bg-white focus:outline-none focus:ring-1 focus:ring-blue-400"
|
||||
value={newType}
|
||||
onChange={(e) => setNewType(e.target.value)}
|
||||
>
|
||||
<option value="text">Short text</option>
|
||||
<option value="textarea">Long text</option>
|
||||
</select>
|
||||
<Button size="sm" type="button" onClick={addField}>+ Add</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400">
|
||||
Drag <span className="font-mono">⠿</span> to reorder · <strong>Hide</strong> removes a builtin field from forms while preserving its data · <strong>🏷</strong> manages quick-fill tags
|
||||
</p>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 border-t pt-4">
|
||||
<Button variant="secondary" onClick={() => onClose(null)} disabled={saving}>Cancel</Button>
|
||||
<Button onClick={save} loading={saving}>Save Template</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,37 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Modal from './Modal';
|
||||
import Button from './Button';
|
||||
|
||||
export default function ConfirmDialog({ isOpen, onClose, onConfirm, title, message, loading }) {
|
||||
export default function ConfirmDialog({ isOpen, onClose, onConfirm, title, message, loading, confirmWord }) {
|
||||
const [typed, setTyped] = useState('');
|
||||
|
||||
useEffect(() => { if (!isOpen) setTyped(''); }, [isOpen]);
|
||||
|
||||
const ready = confirmWord ? typed === confirmWord : true;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
|
||||
<p className="text-sm text-gray-600 mb-6">{message}</p>
|
||||
<p className="text-sm text-gray-600 mb-4">{message}</p>
|
||||
{confirmWord && (
|
||||
<div className="mb-5">
|
||||
<label className="block text-xs text-gray-500 mb-1">
|
||||
Type <span className="font-mono font-semibold text-gray-700">{confirmWord}</span> to confirm
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={typed}
|
||||
onChange={(e) => setTyped(e.target.value)}
|
||||
autoFocus
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-red-300 focus:border-red-400"
|
||||
placeholder={confirmWord}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={onClose} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger" onClick={onConfirm} loading={loading}>
|
||||
<Button variant="danger" onClick={onConfirm} loading={loading} disabled={!ready}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function Modal({ isOpen, onClose, title, children, size = 'md' })
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const widths = { sm: 'max-w-md', md: 'max-w-lg', lg: 'max-w-2xl', xl: 'max-w-4xl' };
|
||||
const widths = { sm: 'max-w-md', md: 'max-w-lg', lg: 'max-w-2xl', xl: 'max-w-4xl', full: 'w-[75vw] max-w-none' };
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// Pure helpers for the cross-subject metrics plot interactions. No React, no I/O.
|
||||
|
||||
// Sort a Recharts tooltip payload by numeric value descending, dropping entries
|
||||
// whose value is null/undefined. Returns a NEW array (does not mutate input).
|
||||
export function sortPayloadByValueDesc(payload) {
|
||||
if (!Array.isArray(payload)) return [];
|
||||
return payload
|
||||
.filter((p) => p && p.value != null)
|
||||
.sort((a, b) => b.value - a.value);
|
||||
}
|
||||
|
||||
// Toggle an id's membership in a Set, returning a NEW Set.
|
||||
export function toggleInSet(set, id) {
|
||||
const next = new Set(set);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
}
|
||||
|
||||
// Add (hidden=true) or remove (hidden=false) a list of ids from a hidden-set.
|
||||
// Returns a NEW Set.
|
||||
export function setIdsHidden(set, ids, hidden) {
|
||||
const next = new Set(set);
|
||||
for (const id of ids) {
|
||||
if (hidden) next.add(id);
|
||||
else next.delete(id);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
// Tri-state for a group checkbox given which subject ids are hidden.
|
||||
// 'all' = all visible, 'none' = all hidden, 'some' = mixed.
|
||||
export function groupVisibilityState(subjectIds, hiddenSet) {
|
||||
if (subjectIds.length === 0) return 'none';
|
||||
const hiddenCount = subjectIds.filter((id) => hiddenSet.has(id)).length;
|
||||
if (hiddenCount === 0) return 'all';
|
||||
if (hiddenCount === subjectIds.length) return 'none';
|
||||
return 'some';
|
||||
}
|
||||
|
||||
// Group chart "lines" by their .group field, preserving first-seen group order
|
||||
// and line order within each group. Returns [{ group, subjects: [line...] }].
|
||||
export function groupLines(lines) {
|
||||
const order = [];
|
||||
const byGroup = new Map();
|
||||
for (const line of lines) {
|
||||
const g = line.group ?? '—';
|
||||
if (!byGroup.has(g)) { byGroup.set(g, []); order.push(g); }
|
||||
byGroup.get(g).push(line);
|
||||
}
|
||||
return order.map((group) => ({ group, subjects: byGroup.get(group) }));
|
||||
}
|
||||
|
||||
// ── Plot config (de)serialization ───────────────────────────────────────────────
|
||||
// Translate between ExperimentAnalysisCharts React state and the stored JSONB blob.
|
||||
// readPlotConfig is defensive: it never throws and fills safe defaults for missing
|
||||
// or malformed input (unknown keys are ignored).
|
||||
|
||||
// Symmetric with readChart so a serialize→read round-trip is lossless.
|
||||
function snapshotChart(s = {}) {
|
||||
const str = (v) => (typeof v === 'string' ? v : '');
|
||||
return {
|
||||
height: Number.isFinite(s.height) ? s.height : undefined,
|
||||
yMin: str(s.yMin),
|
||||
yMax: str(s.yMax),
|
||||
xZoomMin: str(s.xZoomMin),
|
||||
xZoomMax: str(s.xZoomMax),
|
||||
xFieldOverride: typeof s.xFieldOverride === 'string' ? s.xFieldOverride : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializePlotConfig(state = {}) {
|
||||
const { xField, groupBy, tickStep, metric, hiddenSubjects, sidebarOpen, counts, rate } = state;
|
||||
return {
|
||||
xField,
|
||||
groupBy,
|
||||
tickStep,
|
||||
metric,
|
||||
hiddenSubjects: [...(hiddenSubjects ?? [])].sort(),
|
||||
sidebarOpen: !!sidebarOpen,
|
||||
counts: snapshotChart(counts),
|
||||
rate: snapshotChart(rate),
|
||||
};
|
||||
}
|
||||
|
||||
function readChart(c) {
|
||||
const o = c && typeof c === 'object' && !Array.isArray(c) ? c : {};
|
||||
const str = (v) => (typeof v === 'string' ? v : '');
|
||||
return {
|
||||
height: Number.isFinite(o.height) ? o.height : undefined,
|
||||
yMin: str(o.yMin),
|
||||
yMax: str(o.yMax),
|
||||
xZoomMin: str(o.xZoomMin),
|
||||
xZoomMax: str(o.xZoomMax),
|
||||
xFieldOverride: typeof o.xFieldOverride === 'string' ? o.xFieldOverride : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function readPlotConfig(raw) {
|
||||
const c = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
||||
return {
|
||||
xField: typeof c.xField === 'string' ? c.xField : undefined,
|
||||
groupBy: typeof c.groupBy === 'string' ? c.groupBy : undefined,
|
||||
tickStep: Number.isFinite(c.tickStep) ? c.tickStep : undefined,
|
||||
metric: typeof c.metric === 'string' ? c.metric : undefined,
|
||||
hiddenSubjects: Array.isArray(c.hiddenSubjects) ? c.hiddenSubjects.filter((x) => typeof x === 'string') : [],
|
||||
sidebarOpen: c.sidebarOpen === true,
|
||||
counts: readChart(c.counts),
|
||||
rate: readChart(c.rate),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* CSV Analysis Library
|
||||
*
|
||||
* Method summary:
|
||||
*
|
||||
* 1. PARSING
|
||||
* - parseCSVHeaders(text): reads only the first line, splits on comma
|
||||
* (handles double-quoted fields with embedded commas).
|
||||
* - parseCSV(text): full parse into array-of-objects. Each row is
|
||||
* { [headerName]: value, ... }. Empty trailing fields are preserved as ''.
|
||||
*
|
||||
* 2. UNIQUE VALUES
|
||||
* - getUniqueNoteValues(rows, noteCol): scans each row's noteCol,
|
||||
* trims whitespace, ignores empty strings, returns sorted unique values.
|
||||
*
|
||||
* 3. ANALYSIS (runAnalysis)
|
||||
* Input:
|
||||
* rows — full parsed rows
|
||||
* timestampCol — column name to sort by (numeric sort; falls back to row order)
|
||||
* noteCol — column containing note values
|
||||
* classifications — plain object: { noteValue: categoryName }
|
||||
* unclassified notes are included in the sequence
|
||||
* but excluded from category counts and run stats.
|
||||
*
|
||||
* Steps:
|
||||
* a) Filter to rows where noteCol is non-empty.
|
||||
* b) Sort by timestampCol (parseFloat; NaN values go to the end).
|
||||
* c) Map each row to { note, category, timestamp }.
|
||||
* d) CATEGORY COUNTS: for each category, count total occurrences
|
||||
* and per-note-value breakdown.
|
||||
* e) RUN-LENGTH ENCODING (consecutive stat):
|
||||
* Walk the category sequence. Each time the category changes,
|
||||
* start a new run. Uncategorised notes are skipped (do not break
|
||||
* or continue a run). This gives an ordered list of
|
||||
* { category, length } objects representing every streak.
|
||||
* f) CONSECUTIVE STATS per category:
|
||||
* From the runs list, group run lengths per category and compute
|
||||
* totalRuns, maxRun, avgRun, and the full runLengths array.
|
||||
*
|
||||
* Output: { totalNoteRows, sequence, categoryCounts, runs, consecutiveStats }
|
||||
*/
|
||||
|
||||
// ── CSV parsing ────────────────────────────────────────────────────────────────
|
||||
|
||||
function splitCSVLine(line) {
|
||||
const fields = [];
|
||||
let field = '';
|
||||
let inQuotes = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (ch === '"') {
|
||||
if (inQuotes && line[i + 1] === '"') { field += '"'; i++; }
|
||||
else { inQuotes = !inQuotes; }
|
||||
} else if (ch === ',' && !inQuotes) {
|
||||
fields.push(field);
|
||||
field = '';
|
||||
} else {
|
||||
field += ch;
|
||||
}
|
||||
}
|
||||
fields.push(field);
|
||||
return fields;
|
||||
}
|
||||
|
||||
/** Return column names from the first line of a CSV string. */
|
||||
export function parseCSVHeaders(text) {
|
||||
const firstLine = text.split(/\r?\n/)[0];
|
||||
return splitCSVLine(firstLine);
|
||||
}
|
||||
|
||||
/** Parse the full CSV into { headers, rows }. */
|
||||
export function parseCSV(text) {
|
||||
const lines = text.split(/\r?\n/);
|
||||
const headers = splitCSVLine(lines[0]);
|
||||
const rows = [];
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!line.trim()) continue;
|
||||
const values = splitCSVLine(line);
|
||||
const row = {};
|
||||
for (let j = 0; j < headers.length; j++) {
|
||||
row[headers[j]] = values[j] ?? '';
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
return { headers, rows };
|
||||
}
|
||||
|
||||
// ── Unique note values ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Return sorted unique non-empty values found in noteCol. */
|
||||
export function getUniqueNoteValues(rows, noteCol) {
|
||||
const seen = new Set();
|
||||
for (const row of rows) {
|
||||
const v = (row[noteCol] ?? '').trim();
|
||||
if (v) seen.add(v);
|
||||
}
|
||||
return [...seen].sort();
|
||||
}
|
||||
|
||||
// ── Frame gap detection ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check whether a numeric column is strictly consecutive (no gaps, no duplicates).
|
||||
* Returns { consecutive: bool, gaps: [{index, expected, actual, jump}], duplicates: [{index, value}] }
|
||||
* Only examines rows where the column value parses as an integer.
|
||||
*/
|
||||
export function checkFrameConsecutive(rows, frameCol) {
|
||||
const entries = [];
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const v = rows[i][frameCol];
|
||||
if (v == null || String(v).trim() === '') continue;
|
||||
const n = parseInt(v, 10);
|
||||
if (isNaN(n)) continue;
|
||||
entries.push({ rowIndex: i, value: n });
|
||||
}
|
||||
|
||||
const gaps = [];
|
||||
const duplicates = [];
|
||||
for (let i = 1; i < entries.length; i++) {
|
||||
const diff = entries[i].value - entries[i - 1].value;
|
||||
if (diff === 0) {
|
||||
duplicates.push({ rowIndex: entries[i].rowIndex, value: entries[i].value });
|
||||
} else if (diff !== 1) {
|
||||
gaps.push({
|
||||
rowIndex: entries[i].rowIndex,
|
||||
expected: entries[i - 1].value + 1,
|
||||
actual: entries[i].value,
|
||||
jump: diff,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { consecutive: gaps.length === 0 && duplicates.length === 0, gaps, duplicates, total: entries.length };
|
||||
}
|
||||
|
||||
// ── Analysis ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Run the full analysis.
|
||||
*
|
||||
* @param {object[]} rows - Parsed CSV rows
|
||||
* @param {string} timestampCol - Column to sort by
|
||||
* @param {string} noteCol - Column with note values
|
||||
* @param {object} classifications - { noteValue: categoryName }
|
||||
* @returns {object} Analysis result
|
||||
*/
|
||||
export function runAnalysis(rows, timestampCol, noteCol, classifications) {
|
||||
// Compute session end from ALL rows (not just note rows)
|
||||
let sessionEndTs = null;
|
||||
for (const r of rows) {
|
||||
const t = parseFloat(r[timestampCol]);
|
||||
if (!isNaN(t) && (sessionEndTs === null || t > sessionEndTs)) sessionEndTs = t;
|
||||
}
|
||||
|
||||
// a) Filter non-empty notes
|
||||
const noteRows = rows.filter(r => (r[noteCol] ?? '').trim() !== '');
|
||||
|
||||
// b) Sort by timestamp (numeric); preserve original order for equal/NaN timestamps
|
||||
noteRows.sort((a, b) => {
|
||||
const ta = parseFloat(a[timestampCol]);
|
||||
const tb = parseFloat(b[timestampCol]);
|
||||
if (isNaN(ta) && isNaN(tb)) return 0;
|
||||
if (isNaN(ta)) return 1;
|
||||
if (isNaN(tb)) return -1;
|
||||
return ta - tb;
|
||||
});
|
||||
|
||||
// c) Build sequence
|
||||
const sequence = noteRows.map(r => ({
|
||||
note: r[noteCol].trim(),
|
||||
category: classifications[r[noteCol].trim()] ?? null,
|
||||
timestamp: r[timestampCol],
|
||||
}));
|
||||
|
||||
// d) Category counts
|
||||
const categoryCounts = {};
|
||||
for (const { note, category } of sequence) {
|
||||
if (!category) continue;
|
||||
if (!categoryCounts[category]) categoryCounts[category] = { total: 0, byNote: {} };
|
||||
categoryCounts[category].total++;
|
||||
categoryCounts[category].byNote[note] = (categoryCounts[category].byNote[note] || 0) + 1;
|
||||
}
|
||||
|
||||
// e) Run-length encoding (skip uncategorised entries — they neither break nor extend a run)
|
||||
const runs = [];
|
||||
for (const { category } of sequence) {
|
||||
if (!category) continue;
|
||||
if (runs.length === 0 || runs[runs.length - 1].category !== category) {
|
||||
runs.push({ category, length: 1 });
|
||||
} else {
|
||||
runs[runs.length - 1].length++;
|
||||
}
|
||||
}
|
||||
|
||||
// f) Consecutive stats per category
|
||||
const consecutiveStats = {};
|
||||
for (const { category, length } of runs) {
|
||||
if (!consecutiveStats[category]) {
|
||||
consecutiveStats[category] = { runLengths: [], totalRuns: 0, maxRun: 0, avgRun: 0 };
|
||||
}
|
||||
const s = consecutiveStats[category];
|
||||
s.runLengths.push(length);
|
||||
s.totalRuns++;
|
||||
if (length > s.maxRun) s.maxRun = length;
|
||||
}
|
||||
for (const cat of Object.keys(consecutiveStats)) {
|
||||
const s = consecutiveStats[cat];
|
||||
s.avgRun = s.runLengths.reduce((a, b) => a + b, 0) / s.runLengths.length;
|
||||
}
|
||||
|
||||
return {
|
||||
totalNoteRows: noteRows.length,
|
||||
sessionEndTs,
|
||||
sequence,
|
||||
categoryCounts,
|
||||
runs,
|
||||
consecutiveStats,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Numeric extraction (for numerical buckets) ────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract the first signed decimal from a string. Returns null if none found.
|
||||
* Examples: "L25.5" -> 25.5, "R-12" -> -12, "abc" -> null
|
||||
*/
|
||||
export function extractNumber(str) {
|
||||
if (str == null) return null;
|
||||
const m = String(str).match(/-?\d+(\.\d+)?/);
|
||||
return m ? parseFloat(m[0]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a numeric-vs-x series for a numerical bucket.
|
||||
*
|
||||
* @param {object[]} rows - Parsed CSV rows
|
||||
* @param {string} xColName - Column to plot on the x-axis (frame col, or timestamp as fallback)
|
||||
* @param {string} noteCol - Column containing note values
|
||||
* @param {object} bucket - { name, type:'numerical', notes: string[] }
|
||||
* @returns {{points: {x:number,value:number,raw:string}[], avg:number|null, skipped:number, total:number, xLabel:string}}
|
||||
*/
|
||||
export function computeNumericSeries(rows, xColName, noteCol, bucket) {
|
||||
const noteSet = new Set(bucket.notes);
|
||||
const points = [];
|
||||
let skipped = 0;
|
||||
let total = 0;
|
||||
|
||||
for (const r of rows) {
|
||||
const note = String(r[noteCol] ?? '').trim();
|
||||
if (!noteSet.has(note)) continue;
|
||||
total++;
|
||||
const value = extractNumber(note);
|
||||
const x = parseFloat(r[xColName]);
|
||||
if (value == null || Number.isNaN(x)) { skipped++; continue; }
|
||||
points.push({ x, value, raw: note });
|
||||
}
|
||||
|
||||
points.sort((a, b) => a.x - b.x);
|
||||
const avg = points.length === 0
|
||||
? null
|
||||
: points.reduce((s, p) => s + p.value, 0) / points.length;
|
||||
|
||||
return { points, avg, skipped, total, xLabel: xColName };
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
// Pure helpers for exporting daily-parameter data as a CSV matrix. The export is a
|
||||
// subject-series matrix: subjects are always columns, the chosen X coordinate
|
||||
// (Date / # Days Reach / a daily field) provides the rows, and one Data
|
||||
// parameter supplies the cell values.
|
||||
// No React, no I/O — mirrors the crossSubjectChart.js pure-helper pattern.
|
||||
|
||||
// Read a single parameter's raw value from one daily status.
|
||||
// Returns null when the value is absent. Note: 0 and '' are returned as-is
|
||||
// (real values); only genuinely-missing lookups become null.
|
||||
export function extractValue(status, param) {
|
||||
if (!status || !param) return null;
|
||||
switch (param.kind) {
|
||||
case 'total':
|
||||
return status.analysis_summary?.total ?? null;
|
||||
case 'success_rate':
|
||||
return status.analysis_summary?.success_rate ?? null;
|
||||
case 'category':
|
||||
return status.analysis_summary?.counts?.[param.category] ?? null;
|
||||
case 'builtin':
|
||||
return status[param.statusKey] ?? null;
|
||||
case 'custom':
|
||||
return status.custom_fields?.[param.fieldId] ?? null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Build the grouped, ordered list of exportable parameters.
|
||||
// Session metrics (total, success_rate, then each dynamic count category found
|
||||
// in the data) come first; then every field defined in the daily template,
|
||||
// including inactive ones (historical data may exist).
|
||||
export function listExportableParams(dailyTemplate, statuses) {
|
||||
const params = [
|
||||
{ id: '__total__', label: 'Total attempts', group: 'metrics', kind: 'total' },
|
||||
{ id: '__success_rate__', label: 'Success rate', group: 'metrics', kind: 'success_rate' },
|
||||
];
|
||||
|
||||
const cats = new Set();
|
||||
for (const s of statuses ?? []) {
|
||||
const counts = s?.analysis_summary?.counts;
|
||||
if (counts && typeof counts === 'object') {
|
||||
for (const k of Object.keys(counts)) cats.add(k);
|
||||
}
|
||||
}
|
||||
for (const cat of [...cats].sort()) {
|
||||
params.push({ id: `__cat__${cat}`, label: cat, group: 'metrics', kind: 'category', category: cat });
|
||||
}
|
||||
|
||||
for (const f of dailyTemplate ?? []) {
|
||||
if (f.builtin) {
|
||||
params.push({ id: `b:${f.fieldId}`, label: f.label, group: 'daily', kind: 'builtin', statusKey: f.key });
|
||||
} else {
|
||||
params.push({ id: `c:${f.fieldId}`, label: f.label, group: 'daily', kind: 'custom', fieldId: f.fieldId });
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
// Numeric value of a daily field on one status (mirrors the metrics plot's helper).
|
||||
export 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 Number.isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
// Ordered row members for the chosen X coordinate.
|
||||
// xField: '__date__' | '__days__' | <daily fieldId>
|
||||
export function listSampleMembers(statuses, xField, dailyTemplate) {
|
||||
const list = statuses ?? [];
|
||||
if (xField === '__date__') {
|
||||
const keys = new Set();
|
||||
for (const s of list) if (s?.date) keys.add(dateKey(s.date));
|
||||
return [...keys].sort().map((k) => ({ id: k, label: k, sampleValue: k }));
|
||||
}
|
||||
if (xField === '__days__') {
|
||||
const counts = new Map();
|
||||
for (const s of list) {
|
||||
if (s?.animal_id == null || !s?.date) continue;
|
||||
counts.set(s.animal_id, (counts.get(s.animal_id) ?? 0) + 1);
|
||||
}
|
||||
const max = counts.size ? Math.max(...counts.values()) : 0;
|
||||
return Array.from({ length: max }, (_, i) => ({ id: String(i + 1), label: String(i + 1), sampleValue: i + 1 }));
|
||||
}
|
||||
const field = (dailyTemplate ?? []).find((f) => f.fieldId === xField);
|
||||
const vals = new Set();
|
||||
for (const s of list) {
|
||||
const v = numericFieldVal(s, field);
|
||||
if (v !== null) vals.add(v);
|
||||
}
|
||||
return [...vals].sort((a, b) => a - b).map((v) => ({ id: String(v), label: String(v), sampleValue: v }));
|
||||
}
|
||||
|
||||
// animalId -> Map(sampleValue -> status). First status per (subject, sampleValue) wins,
|
||||
// scanning each subject's statuses in ascending date order.
|
||||
export function buildSampleIndex(statuses, xField, dailyTemplate) {
|
||||
const bySubject = new Map();
|
||||
for (const s of statuses ?? []) {
|
||||
if (s?.animal_id == null || !s?.date) continue;
|
||||
if (!bySubject.has(s.animal_id)) bySubject.set(s.animal_id, []);
|
||||
bySubject.get(s.animal_id).push(s);
|
||||
}
|
||||
const field = xField !== '__date__' && xField !== '__days__'
|
||||
? (dailyTemplate ?? []).find((f) => f.fieldId === xField)
|
||||
: null;
|
||||
|
||||
const index = new Map();
|
||||
for (const [animalId, subjStatuses] of bySubject) {
|
||||
const ordered = [...subjStatuses].sort((a, b) => dateKey(a.date).localeCompare(dateKey(b.date)));
|
||||
const m = new Map();
|
||||
ordered.forEach((s, i) => {
|
||||
let key;
|
||||
if (xField === '__date__') key = dateKey(s.date);
|
||||
else if (xField === '__days__') key = i + 1;
|
||||
else { const v = numericFieldVal(s, field); if (v === null) return; key = v; }
|
||||
if (!m.has(key)) m.set(key, s);
|
||||
});
|
||||
index.set(animalId, m);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function xFieldLabel(xField, dailyTemplate) {
|
||||
if (xField === '__date__') return 'Date';
|
||||
if (xField === '__days__') return '# Days Reach (ordinal)';
|
||||
return (dailyTemplate ?? []).find((f) => f.fieldId === xField)?.label ?? '';
|
||||
}
|
||||
|
||||
// Subjects as columns, X-field values as rows, one Data value per cell.
|
||||
// Returns the { context, corner, groupAxis, columns, rows } shape toCSV consumes.
|
||||
export function buildSubjectSeriesMatrix({ xField, dataParam, groupField }, { statuses, animals, dailyTemplate }) {
|
||||
const columns = listSubjectMembers(animals, groupField);
|
||||
const rowMembers = listSampleMembers(statuses, xField, dailyTemplate);
|
||||
const index = buildSampleIndex(statuses, xField, dailyTemplate);
|
||||
|
||||
const cellValue = (sampleValue, animalId) => {
|
||||
const status = index.get(animalId)?.get(sampleValue);
|
||||
if (!status || !dataParam) return '';
|
||||
const v = extractValue(status, dataParam);
|
||||
return hasValue(v) ? v : '';
|
||||
};
|
||||
|
||||
let rows = rowMembers.map((rm) => ({
|
||||
member: rm,
|
||||
values: columns.map((c) => cellValue(rm.sampleValue, c.animalId)),
|
||||
}));
|
||||
rows = rows.filter((r) => r.values.some((v) => v !== '')); // drop entirely-blank sample rows
|
||||
|
||||
const grouped = !!groupField && groupField !== '__none__';
|
||||
return {
|
||||
corner: xFieldLabel(xField, dailyTemplate),
|
||||
context: { label: 'Data', value: dataParam?.label ?? '' },
|
||||
groupAxis: grouped ? 'col' : null,
|
||||
columns,
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve a subject's group value for a given group field id.
|
||||
// null -> no grouping (field is falsy or '__none__')
|
||||
// '—' -> grouping is on but this subject has no value
|
||||
export function subjectGroupValue(animal, groupField) {
|
||||
if (!groupField || groupField === '__none__') return null;
|
||||
if (groupField === '__name__') return animal?.animal_name ?? '—';
|
||||
if (groupField === '__id__') return animal?.animal_id_string ?? '—';
|
||||
const v = animal?.subject_info?.[groupField];
|
||||
return v === null || v === undefined || v === '' ? '—' : v;
|
||||
}
|
||||
|
||||
export function listSubjectMembers(animals, groupField) {
|
||||
const list = [...(animals ?? [])];
|
||||
const nameCounts = new Map();
|
||||
for (const a of list) {
|
||||
const n = a?.animal_name ?? '';
|
||||
nameCounts.set(n, (nameCounts.get(n) ?? 0) + 1);
|
||||
}
|
||||
const members = list.map((a) => {
|
||||
const name = a?.animal_name ?? '';
|
||||
const label = nameCounts.get(name) > 1 ? `${name} (${a?.animal_id_string ?? ''})` : name;
|
||||
return { id: a?.id, label, animalId: a?.id, group: subjectGroupValue(a, groupField) };
|
||||
});
|
||||
const grouped = !!groupField && groupField !== '__none__';
|
||||
members.sort((x, y) => {
|
||||
if (grouped) {
|
||||
const g = String(x.group ?? '').localeCompare(String(y.group ?? ''));
|
||||
if (g !== 0) return g;
|
||||
}
|
||||
return String(x.label).localeCompare(String(y.label));
|
||||
});
|
||||
return members;
|
||||
}
|
||||
|
||||
// A cell has a value unless it is null, undefined, or the empty string.
|
||||
// (0 and false are real values.)
|
||||
function hasValue(v) {
|
||||
return v !== null && v !== undefined && v !== '';
|
||||
}
|
||||
|
||||
function dateKey(date) {
|
||||
return String(date).slice(0, 10); // ISO datetime or date → YYYY-MM-DD
|
||||
}
|
||||
|
||||
function escapeCsvCell(value) {
|
||||
const s = value === null || value === undefined ? '' : String(value);
|
||||
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
||||
}
|
||||
|
||||
// Serialize a matrix to an RFC-4180-ish CSV string (LF line endings):
|
||||
// Data,<metric>
|
||||
// (blank line)
|
||||
// [Group,<group per subject column>] -- only when groupAxis === 'col'
|
||||
// <corner>[,Group?],<column headers...>
|
||||
// <row label>[,group?],<values...>
|
||||
export function toCSV(matrix) {
|
||||
const lines = [];
|
||||
lines.push([matrix.context.label, matrix.context.value].map(escapeCsvCell).join(','));
|
||||
lines.push('');
|
||||
|
||||
if (matrix.groupAxis === 'col') {
|
||||
const groupRow = ['Group', ...matrix.columns.map((c) => c.group ?? '—')];
|
||||
lines.push(groupRow.map(escapeCsvCell).join(','));
|
||||
}
|
||||
|
||||
// toCSV is a general serializer; the subject-series builder never emits groupAxis
|
||||
// === 'row' (only 'col' or null), but the branch is retained for that generality.
|
||||
const header = matrix.groupAxis === 'row'
|
||||
? ['Group', matrix.corner, ...matrix.columns.map((c) => c.label)]
|
||||
: [matrix.corner, ...matrix.columns.map((c) => c.label)];
|
||||
lines.push(header.map(escapeCsvCell).join(','));
|
||||
|
||||
for (const r of matrix.rows) {
|
||||
const lead = matrix.groupAxis === 'row'
|
||||
? [r.member.group ?? '—', r.member.label]
|
||||
: [r.member.label];
|
||||
lines.push([...lead, ...r.values].map(escapeCsvCell).join(','));
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Build a download filename from the experiment title and parameter label.
|
||||
export function csvFilename(title, label) {
|
||||
const slug = (s) => String(s ?? '').replace(/[^a-z0-9]+/gi, '-').replace(/^-+|-+$/g, '');
|
||||
const base = [slug(title), slug(label)].filter(Boolean).join('-');
|
||||
return `${base || 'export'}.csv`;
|
||||
}
|
||||
@@ -1,20 +1,119 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { animalsApi, dailyStatusesApi } from '../api/client';
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useParams, useNavigate, Link, useLocation } from 'react-router-dom';
|
||||
import { animalsApi, dailyStatusesApi, experimentsApi } from '../api/client';
|
||||
import { format } from 'date-fns';
|
||||
import Button from '../components/ui/Button';
|
||||
import Modal from '../components/ui/Modal';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
import DailyStatusForm from '../components/DailyStatusForm';
|
||||
import SubjectInfoForm from '../components/SubjectInfoForm';
|
||||
import Alert from '../components/ui/Alert';
|
||||
import AuditLogSection from '../components/AuditLogSection';
|
||||
import { SubjectAnalysisCharts } from '../components/AnalysisCharts';
|
||||
|
||||
/** Read the value for a template field from a status record.
|
||||
* Custom fields are keyed by fieldId, not by the display key. */
|
||||
function getFieldValue(status, field) {
|
||||
if (field.builtin) return status[field.key];
|
||||
return status.custom_fields?.[field.fieldId];
|
||||
}
|
||||
|
||||
// ── Inline cell editor ─────────────────────────────────────────────────────────
|
||||
|
||||
function InlineCell({ statusId, field, value, currentStatus, onSaved }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState(null);
|
||||
const inputRef = useRef(null);
|
||||
const isTextarea = field.type === 'textarea';
|
||||
|
||||
function startEdit(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDraft(value ?? '');
|
||||
setSaveError(null);
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (editing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
if (inputRef.current.select) inputRef.current.select();
|
||||
}
|
||||
}, [editing]);
|
||||
|
||||
async function commit() {
|
||||
setEditing(false);
|
||||
const newValue = draft.trim() || null;
|
||||
if (newValue === (value?.trim() || null)) return;
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
let body;
|
||||
if (field.builtin) {
|
||||
body = { [field.key]: newValue };
|
||||
} else {
|
||||
body = { custom_fields: { ...currentStatus.custom_fields, [field.fieldId]: newValue } };
|
||||
}
|
||||
const updated = await dailyStatusesApi.update(statusId, body);
|
||||
onSaved(updated);
|
||||
} catch (err) {
|
||||
setSaveError(err.message ?? 'Save failed');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() { setEditing(false); }
|
||||
|
||||
if (editing) {
|
||||
const sharedProps = {
|
||||
ref: inputRef,
|
||||
value: draft,
|
||||
onChange: (e) => setDraft(e.target.value),
|
||||
onBlur: commit,
|
||||
onClick: (e) => e.stopPropagation(),
|
||||
onKeyDown: (e) => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); cancel(); }
|
||||
if (e.key === 'Enter' && !isTextarea) { e.preventDefault(); commit(); }
|
||||
},
|
||||
className: 'w-full border border-indigo-400 rounded px-1.5 py-0.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-300 bg-white',
|
||||
};
|
||||
return isTextarea
|
||||
? <textarea {...sharedProps} rows={3} className={sharedProps.className + ' resize-none'} />
|
||||
: <input type="text" {...sharedProps} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onDoubleClick={startEdit}
|
||||
title={editing ? '' : 'Double-click to edit'}
|
||||
className={`min-h-[1.25rem] cursor-default rounded transition-colors px-0.5 -mx-0.5
|
||||
${saving ? 'opacity-40' : 'hover:bg-indigo-50/60 group/ic'}`}
|
||||
>
|
||||
{saveError
|
||||
? <span className="text-red-500 text-xs">{saveError}</span>
|
||||
: value
|
||||
? isTextarea
|
||||
? <span className="line-clamp-2 whitespace-pre-wrap">{value}</span>
|
||||
: <span className="truncate block">{value}</span>
|
||||
: <span className="text-gray-200 group-hover/ic:text-gray-400 select-none text-xs">· · ·</span>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AnimalDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const [animal, setAnimal] = useState(null);
|
||||
const [statuses, setStatuses] = useState([]);
|
||||
const [template, setTemplate] = useState([]);
|
||||
const [subjectTemplate, setSubjectTemplate] = useState([]);
|
||||
const [showSubjectInfo, setShowSubjectInfo] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [successMsg, setSuccessMsg] = useState(null);
|
||||
@@ -23,6 +122,40 @@ export default function AnimalDetail() {
|
||||
const [editStatus, setEditStatus] = useState(null);
|
||||
const [deleteStatus, setDeleteStatus] = useState(null);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [showDeleteSubject, setShowDeleteSubject] = useState(false);
|
||||
const [deleteSubjectLoading, setDeleteSubjectLoading] = useState(false);
|
||||
const [fillingGaps, setFillingGaps] = useState(false);
|
||||
|
||||
// Column visibility — persisted per experiment
|
||||
const [hiddenCols, setHiddenCols] = useState(new Set());
|
||||
const [showColPicker, setShowColPicker] = useState(false);
|
||||
const colPickerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!animal) return;
|
||||
const stored = localStorage.getItem(`col-vis-${animal.id}`);
|
||||
setHiddenCols(stored ? new Set(JSON.parse(stored)) : new Set());
|
||||
}, [animal?.id]);
|
||||
|
||||
function toggleCol(fieldId) {
|
||||
setHiddenCols((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(fieldId) ? next.delete(fieldId) : next.add(fieldId);
|
||||
localStorage.setItem(`col-vis-${animal.id}`, JSON.stringify([...next]));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!showColPicker) return;
|
||||
function handleClick(e) {
|
||||
if (colPickerRef.current && !colPickerRef.current.contains(e.target)) {
|
||||
setShowColPicker(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, [showColPicker]);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
@@ -34,6 +167,13 @@ export default function AnimalDetail() {
|
||||
]);
|
||||
setAnimal(animalData);
|
||||
setStatuses(statusData);
|
||||
|
||||
const [tmpl, subjTmpl] = await Promise.all([
|
||||
experimentsApi.getTemplate(animalData.experiment_id),
|
||||
experimentsApi.getSubjectTemplate(animalData.experiment_id),
|
||||
]);
|
||||
setTemplate(tmpl);
|
||||
setSubjectTemplate(subjTmpl);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@@ -60,6 +200,7 @@ export default function AnimalDetail() {
|
||||
try {
|
||||
await dailyStatusesApi.delete(deleteStatus.id);
|
||||
setDeleteStatus(null);
|
||||
setEditStatus(null);
|
||||
load();
|
||||
flash('Daily status removed.');
|
||||
} catch (err) {
|
||||
@@ -70,7 +211,33 @@ export default function AnimalDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="max-w-5xl mx-auto px-4 py-8 text-gray-400">Loading…</div>;
|
||||
async function fillGaps() {
|
||||
setFillingGaps(true);
|
||||
try {
|
||||
await Promise.all(missingDates.map((date) => dailyStatusesApi.create({ date, animal_id: id })));
|
||||
load();
|
||||
flash(`Created ${missingDates.length} missing entr${missingDates.length === 1 ? 'y' : 'ies'}.`);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setFillingGaps(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteSubject() {
|
||||
setDeleteSubjectLoading(true);
|
||||
try {
|
||||
await animalsApi.delete(animal.id);
|
||||
navigate(`/experiments/${animal.experiment_id}`, { replace: true });
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setShowDeleteSubject(false);
|
||||
} finally {
|
||||
setDeleteSubjectLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="max-w-5xl mx-auto px-4 py-8 text-gray-400" aria-live="polite" aria-busy="true">Loading…</div>;
|
||||
if (error) return (
|
||||
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||
<Alert type="error" message={error} />
|
||||
@@ -78,15 +245,32 @@ export default function AnimalDetail() {
|
||||
</div>
|
||||
);
|
||||
|
||||
const activeFields = template.filter((f) => f.active);
|
||||
const visibleFields = activeFields.filter((f) => !hiddenCols.has(f.fieldId));
|
||||
|
||||
// Dates with entries
|
||||
const existingDateSet = new Set(statuses.map((s) => String(s.date).slice(0, 10)));
|
||||
const missingDates = (() => {
|
||||
if (statuses.length < 2) return [];
|
||||
const sorted = [...existingDateSet].sort();
|
||||
const missing = [];
|
||||
const cur = new Date(sorted[0] + 'T12:00:00');
|
||||
const end = new Date(sorted[sorted.length - 1] + 'T12:00:00');
|
||||
while (cur < end) {
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
const d = cur.toISOString().slice(0, 10);
|
||||
if (!existingDateSet.has(d)) missing.push(d);
|
||||
}
|
||||
return missing;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="text-sm text-gray-500 mb-4">
|
||||
<Link to="/" className="hover:text-blue-600">Experiments</Link>
|
||||
<span className="mx-2">/</span>
|
||||
<Link to={`/experiments/${animal.experiment_id}`} className="hover:text-blue-600">
|
||||
Experiment
|
||||
</Link>
|
||||
<Link to={`/experiments/${animal.experiment_id}`} className="hover:text-blue-600">Experiment</Link>
|
||||
<span className="mx-2">/</span>
|
||||
<span className="text-gray-900 font-medium">{animal.animal_name}</span>
|
||||
</nav>
|
||||
@@ -106,69 +290,267 @@ export default function AnimalDetail() {
|
||||
{successMsg && <Alert type="success" message={successMsg} />}
|
||||
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
|
||||
|
||||
{/* Subject information card */}
|
||||
{subjectTemplate.filter((f) => f.active).length > 0 && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5 mb-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">Subject Information</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSubjectInfo(true)}
|
||||
className="text-xs text-gray-400 hover:text-blue-600 transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536M9 13l6.586-6.586a2 2 0 112.828 2.828L11.828 15.828A2 2 0 0110 16.414V19h2.586a2 2 0 001.414-.586l6.5-6.5" />
|
||||
</svg>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
{(() => {
|
||||
const filled = subjectTemplate.filter((f) => f.active && animal.subject_info?.[f.fieldId]);
|
||||
if (filled.length === 0) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSubjectInfo(true)}
|
||||
className="text-sm text-gray-400 italic hover:text-blue-600 transition-colors"
|
||||
>
|
||||
No information recorded yet — click to add
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||
{filled.map((f) => (
|
||||
<div key={f.fieldId} className={f.type === 'textarea' ? 'sm:col-span-2' : ''}>
|
||||
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide">{f.label}</dt>
|
||||
<dd className="text-gray-800 mt-0.5 whitespace-pre-wrap">{animal.subject_info[f.fieldId]}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{statuses.length === 0 ? (
|
||||
<div className="text-center py-16 border-2 border-dashed border-gray-200 rounded-xl">
|
||||
<p className="text-gray-400 mb-3">No daily statuses logged yet.</p>
|
||||
<Button onClick={() => setShowAddStatus(true)}>+ Log First Status</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{statuses.map((s) => (
|
||||
<div key={s.id} className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="font-semibold text-gray-900">
|
||||
{format(new Date(s.date), 'MMMM d, yyyy')}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => setEditStatus(s)}>Edit</Button>
|
||||
<Button size="sm" variant="danger" onClick={() => setDeleteStatus(s)}>Delete</Button>
|
||||
</div>
|
||||
<div className="border border-gray-200 rounded-xl overflow-hidden">
|
||||
{/* Column picker toolbar */}
|
||||
{activeFields.length > 0 && (
|
||||
<div className="flex justify-end px-3 py-1.5 border-b border-gray-100 bg-gray-50/60">
|
||||
<div className="relative" ref={colPickerRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowColPicker((v) => !v)}
|
||||
className="inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-800 px-2 py-1 rounded hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
|
||||
</svg>
|
||||
Columns
|
||||
{hiddenCols.size > 0 && (
|
||||
<span className="ml-0.5 bg-indigo-100 text-indigo-700 rounded-full px-1.5 py-px text-[10px] font-medium leading-none">
|
||||
{activeFields.length - hiddenCols.size}/{activeFields.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{showColPicker && (
|
||||
<div className="absolute right-0 top-full mt-1 z-30 bg-white border border-gray-200 rounded-lg shadow-lg p-2 min-w-[180px]">
|
||||
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide px-1 mb-1">Show / hide columns</p>
|
||||
{activeFields.map((f) => {
|
||||
const visible = !hiddenCols.has(f.fieldId);
|
||||
return (
|
||||
<label key={f.fieldId} className="flex items-center gap-2 px-1 py-0.5 rounded hover:bg-gray-50 cursor-pointer select-none text-xs text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={visible}
|
||||
onChange={() => toggleCol(f.fieldId)}
|
||||
className="w-3.5 h-3.5 accent-indigo-600"
|
||||
/>
|
||||
<span className="truncate" title={f.label}>{f.label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{hiddenCols.size > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setHiddenCols(new Set());
|
||||
localStorage.removeItem(`col-vis-${animal.id}`);
|
||||
}}
|
||||
className="mt-1 w-full text-left text-[10px] text-indigo-500 hover:text-indigo-700 px-1 py-0.5"
|
||||
>
|
||||
Show all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||
{[
|
||||
['Experiment Description', s.experiment_description],
|
||||
['Vitals', s.vitals],
|
||||
['Treatment', s.treatment],
|
||||
['Notes', s.notes],
|
||||
]
|
||||
.filter(([, v]) => v)
|
||||
.map(([label, value]) => (
|
||||
<div key={label} className="flex flex-col">
|
||||
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide">{label}</dt>
|
||||
<dd className="text-gray-800 mt-0.5 whitespace-pre-wrap">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{!s.experiment_description && !s.vitals && !s.treatment && !s.notes && (
|
||||
<p className="text-sm text-gray-400 italic">No details recorded for this date.</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="text-sm border-collapse min-w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide whitespace-nowrap sticky left-0 bg-gray-50 z-10 border-r border-gray-200 min-w-[110px]">
|
||||
Date
|
||||
</th>
|
||||
{visibleFields.map((f) => {
|
||||
const header = f.abbr ? (f.unit ? `${f.abbr} (${f.unit})` : f.abbr) : (f.unit ? `${f.label} (${f.unit})` : f.label);
|
||||
const tooltip = f.unit ? `${f.label} (${f.unit})` : f.label;
|
||||
const minW = Math.max(80, header.length * 7.5 + 24);
|
||||
return (
|
||||
<th key={f.key} title={tooltip} style={{ minWidth: minW }} className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide whitespace-nowrap max-w-[220px] cursor-default">
|
||||
{header}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
{activeFields.length === 0 && (
|
||||
<>
|
||||
{['Experiment Description', 'Vitals', 'Treatment', 'Notes'].map((l) => (
|
||||
<th key={l} className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide whitespace-nowrap min-w-[120px]">{l}</th>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<th className="sticky right-0 bg-gray-50 z-10 border-l border-gray-200 px-3 py-2 min-w-[96px]" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{statuses.map((s, i) => {
|
||||
const rowBg = i % 2 === 0 ? 'bg-white' : 'bg-gray-50/40';
|
||||
const renderFields = activeFields.length > 0 ? visibleFields : [
|
||||
{ fieldId: '__exp_desc__', key: 'experiment_description', label: 'Experiment Description', type: 'textarea', builtin: true, active: true },
|
||||
{ fieldId: '__vitals__', key: 'vitals', label: 'Vitals', type: 'text', builtin: true, active: true },
|
||||
{ fieldId: '__treatment__',key: 'treatment', label: 'Treatment', type: 'text', builtin: true, active: true },
|
||||
{ fieldId: '__notes__', key: 'notes', label: 'Notes', type: 'textarea', builtin: true, active: true },
|
||||
];
|
||||
return (
|
||||
<tr key={s.id} className={`border-b border-gray-100 last:border-0 ${rowBg}`}>
|
||||
<td
|
||||
className={`px-3 py-2 font-medium text-gray-700 whitespace-nowrap sticky left-0 z-10 border-r border-gray-100 cursor-pointer hover:text-blue-600 hover:bg-blue-50/40 transition-colors ${rowBg}`}
|
||||
onClick={() => navigate(`/daily-statuses/${s.id}`)}
|
||||
>
|
||||
{format(new Date(String(s.date).slice(0, 10) + 'T12:00:00'), 'MMM d, yyyy')}
|
||||
</td>
|
||||
{renderFields.map((f) => (
|
||||
<td key={f.fieldId} className="px-3 py-1.5 text-gray-700 max-w-[220px]">
|
||||
<InlineCell
|
||||
statusId={s.id}
|
||||
field={f}
|
||||
value={getFieldValue(s, f)}
|
||||
currentStatus={s}
|
||||
onSaved={(updated) => setStatuses((prev) => prev.map((x) => x.id === s.id ? { ...x, ...updated } : x))}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td className={`px-2 py-1.5 sticky right-0 z-10 border-l border-gray-100 ${rowBg}`}>
|
||||
<div className="flex gap-1 justify-end">
|
||||
<Button size="sm" variant="secondary" onClick={() => setEditStatus(s)}>Edit</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AuditLogSection tableName="daily_statuses" recordId={undefined} />
|
||||
{missingDates.length > 0 && (
|
||||
<div className="mt-3 flex items-center justify-between px-4 py-3 bg-amber-50 border border-amber-200 rounded-xl">
|
||||
<p className="text-sm text-amber-800">
|
||||
<span className="font-semibold">{missingDates.length}</span> date{missingDates.length !== 1 ? 's' : ''} between the earliest and latest entries have no record.
|
||||
</p>
|
||||
<Button variant="secondary" size="sm" onClick={fillGaps} loading={fillingGaps}>
|
||||
Create {missingDates.length} empty entr{missingDates.length === 1 ? 'y' : 'ies'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal isOpen={showAddStatus} onClose={() => setShowAddStatus(false)} title="Log Daily Status" size="lg">
|
||||
<DailyStatusForm animalId={id} onSuccess={handleStatusSaved} onCancel={() => setShowAddStatus(false)} />
|
||||
</Modal>
|
||||
<SubjectAnalysisCharts statuses={statuses} dailyTemplate={template} />
|
||||
|
||||
<Modal isOpen={!!editStatus} onClose={() => setEditStatus(null)} title="Edit Daily Status" size="lg">
|
||||
<DailyStatusForm
|
||||
existing={editStatus}
|
||||
animalId={id}
|
||||
onSuccess={handleStatusSaved}
|
||||
onCancel={() => setEditStatus(null)}
|
||||
<AuditLogSection tableName="daily_statuses" limit={5} />
|
||||
|
||||
{/* Danger zone */}
|
||||
<div className="mt-10 border border-red-200 rounded-xl p-4 bg-red-50/40">
|
||||
<h2 className="text-xs font-semibold text-red-600 uppercase tracking-wide mb-2">Danger Zone</h2>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-600">Permanently delete this subject and all its daily status records.</p>
|
||||
<Button variant="danger" onClick={() => setShowDeleteSubject(true)}>Delete Subject</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal isOpen={showSubjectInfo} onClose={() => setShowSubjectInfo(false)} title="Subject Information" size="full">
|
||||
<SubjectInfoForm
|
||||
animal={animal}
|
||||
subjectTemplate={subjectTemplate}
|
||||
experimentId={animal?.experiment_id}
|
||||
onTemplateUpdate={setSubjectTemplate}
|
||||
onSaved={(updated) => { setAnimal(updated); setShowSubjectInfo(false); flash('Subject information saved.'); }}
|
||||
onCancel={() => setShowSubjectInfo(false)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal isOpen={showAddStatus} onClose={() => setShowAddStatus(false)} title="Log Daily Status" size="full">
|
||||
<DailyStatusForm
|
||||
animalId={id}
|
||||
experimentId={animal?.experiment_id}
|
||||
template={template}
|
||||
onTemplateUpdate={setTemplate}
|
||||
onSuccess={handleStatusSaved}
|
||||
onCancel={() => setShowAddStatus(false)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{(() => {
|
||||
const editIdx = editStatus ? statuses.findIndex((s) => s.id === editStatus.id) : -1;
|
||||
return (
|
||||
<Modal isOpen={!!editStatus} onClose={() => setEditStatus(null)} title="Edit Daily Status" size="full">
|
||||
<DailyStatusForm
|
||||
existing={editStatus}
|
||||
animalId={id}
|
||||
experimentId={animal?.experiment_id}
|
||||
template={template}
|
||||
onTemplateUpdate={setTemplate}
|
||||
onSuccess={handleStatusSaved}
|
||||
onCancel={() => setEditStatus(null)}
|
||||
onRequestDelete={() => setDeleteStatus(editStatus)}
|
||||
hasPrev={editIdx > 0}
|
||||
hasNext={editIdx < statuses.length - 1}
|
||||
onNavigate={(direction, savedResult) => {
|
||||
setStatuses((prev) => prev.map((s) => s.id === savedResult.id ? savedResult : s));
|
||||
setEditStatus(statuses[direction === 'prev' ? editIdx - 1 : editIdx + 1]);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
})()}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={!!deleteStatus}
|
||||
onClose={() => setDeleteStatus(null)}
|
||||
onConfirm={handleDeleteStatus}
|
||||
loading={deleteLoading}
|
||||
title="Delete Daily Status"
|
||||
message={`Delete the status entry for ${deleteStatus ? format(new Date(deleteStatus.date), 'MMMM d, yyyy') : ''}?`}
|
||||
message={`Delete the status entry for ${deleteStatus ? format(new Date(String(deleteStatus.date).slice(0, 10) + 'T12:00:00'), 'MMMM d, yyyy') : ''}? This cannot be undone.`}
|
||||
confirmWord="delete"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={showDeleteSubject}
|
||||
onClose={() => setShowDeleteSubject(false)}
|
||||
onConfirm={handleDeleteSubject}
|
||||
loading={deleteSubjectLoading}
|
||||
title="Delete Subject"
|
||||
message={`This will permanently delete "${animal.animal_name}" and all ${statuses.length} daily status record${statuses.length !== 1 ? 's' : ''}. This cannot be undone.`}
|
||||
confirmWord="delete"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,764 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { format } from 'date-fns';
|
||||
import { dailyStatusesApi, experimentsApi, analysesApi } from '../api/client';
|
||||
import Button from '../components/ui/Button';
|
||||
import Modal from '../components/ui/Modal';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
import DailyStatusForm from '../components/DailyStatusForm';
|
||||
import Alert from '../components/ui/Alert';
|
||||
import CsvAnalysis from '../components/CsvAnalysis';
|
||||
import AuditLogSection from '../components/AuditLogSection';
|
||||
import RunSequenceView from '../components/RunSequenceView';
|
||||
import FileDropZone from '../components/FileDropZone';
|
||||
|
||||
function fmtDate(raw) {
|
||||
return format(new Date(String(raw).slice(0, 10) + 'T12:00:00'), 'MMMM d, yyyy');
|
||||
}
|
||||
|
||||
function fmtDateTime(raw) {
|
||||
return format(new Date(raw), 'MMM d, yyyy · h:mm a');
|
||||
}
|
||||
|
||||
function getFieldValue(status, field) {
|
||||
if (field.builtin) return status[field.key];
|
||||
return status.custom_fields?.[field.fieldId];
|
||||
}
|
||||
|
||||
// ── Analysis summary card ─────────────────────────────────────────────────────
|
||||
|
||||
function AnalysisSummaryCard({ summary, statusId, onSummaryUpdated }) {
|
||||
const { counts = {}, total, success_rate, computed_at } = summary;
|
||||
const COLORS = {
|
||||
Success: 'bg-green-100 text-green-800',
|
||||
Failure: 'bg-red-100 text-red-800',
|
||||
Other: 'bg-gray-100 text-gray-700',
|
||||
};
|
||||
const EXTRA = ['bg-indigo-100 text-indigo-800', 'bg-yellow-100 text-yellow-800', 'bg-purple-100 text-purple-800', 'bg-pink-100 text-pink-800'];
|
||||
const catList = Object.entries(counts).sort(([, a], [, b]) => b - a);
|
||||
let extraIdx = 0;
|
||||
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl px-5 py-4 mb-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Session metrics</p>
|
||||
<div className="flex items-center gap-3">
|
||||
{computed_at && <p className="text-[10px] text-gray-400">Updated {fmtDateTime(computed_at)}</p>}
|
||||
{!showForm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowForm(true)}
|
||||
className="text-[10px] text-gray-400 hover:text-indigo-600 border border-dashed border-gray-200 hover:border-indigo-300 rounded px-2 py-0.5 transition-colors"
|
||||
>
|
||||
Edit manually
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showForm ? (
|
||||
<ManualMetricsForm
|
||||
statusId={statusId}
|
||||
existing={summary}
|
||||
onSaved={(updated) => {
|
||||
onSummaryUpdated({ ...updated, computed_at: new Date().toISOString() });
|
||||
setShowForm(false);
|
||||
}}
|
||||
onClose={() => setShowForm(false)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
{catList.map(([cat, n]) => {
|
||||
const cls = COLORS[cat] ?? EXTRA[extraIdx++ % EXTRA.length];
|
||||
return (
|
||||
<div key={cat} className={`flex flex-col items-center px-3 py-2 rounded-lg ${cls}`}>
|
||||
<span className="text-xs font-medium">{cat}</span>
|
||||
<span className="text-xl font-bold leading-tight">{n}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="flex flex-col items-center px-3 py-2 rounded-lg bg-blue-50 text-blue-800">
|
||||
<span className="text-xs font-medium">Total</span>
|
||||
<span className="text-xl font-bold leading-tight">{total}</span>
|
||||
</div>
|
||||
{success_rate != null && (
|
||||
<div className="flex flex-col items-center px-3 py-2 rounded-lg bg-indigo-50 text-indigo-800">
|
||||
<span className="text-xs font-medium">Success rate</span>
|
||||
<span className="text-xl font-bold leading-tight">{(success_rate * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Manual metrics form ───────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_CATS = ['Success', 'Failure', 'Other'];
|
||||
|
||||
function ManualMetricsForm({ statusId, existing, onSaved, onClose }) {
|
||||
const initCounts = () => {
|
||||
const base = { ...existing?.counts };
|
||||
for (const c of DEFAULT_CATS) if (!(c in base)) base[c] = 0;
|
||||
return base;
|
||||
};
|
||||
|
||||
const [counts, setCounts] = useState(initCounts);
|
||||
const [newCat, setNewCat] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const total = Object.values(counts).reduce((a, b) => a + (Number(b) || 0), 0);
|
||||
const successRate = total > 0 && counts['Success'] != null
|
||||
? (Number(counts['Success']) || 0) / total
|
||||
: null;
|
||||
|
||||
function setCount(cat, raw) {
|
||||
const v = Math.max(0, parseInt(raw, 10) || 0);
|
||||
setCounts((prev) => ({ ...prev, [cat]: v }));
|
||||
}
|
||||
|
||||
function addCat() {
|
||||
const name = newCat.trim();
|
||||
if (!name || name in counts) return;
|
||||
setCounts((prev) => ({ ...prev, [name]: 0 }));
|
||||
setNewCat('');
|
||||
}
|
||||
|
||||
function removeCat(cat) {
|
||||
if (DEFAULT_CATS.includes(cat)) return;
|
||||
setCounts((prev) => { const n = { ...prev }; delete n[cat]; return n; });
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const finalCounts = Object.fromEntries(
|
||||
Object.entries(counts).map(([k, v]) => [k, Number(v) || 0])
|
||||
);
|
||||
await dailyStatusesApi.updateAnalysisSummary(statusId, {
|
||||
counts: finalCounts,
|
||||
total,
|
||||
success_rate: successRate,
|
||||
source_analysis_id: null,
|
||||
});
|
||||
onSaved({ counts: finalCounts, total, success_rate: successRate });
|
||||
} catch (err) {
|
||||
setError(err.message ?? 'Failed to save');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl px-5 py-4 mb-6">
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">Set metrics manually</p>
|
||||
<div className="flex flex-wrap gap-3 items-end mb-3">
|
||||
{Object.entries(counts).map(([cat, val]) => (
|
||||
<div key={cat} className="flex flex-col items-center gap-1">
|
||||
<span className="text-xs text-gray-500">{cat}</span>
|
||||
<input
|
||||
type="number" min={0} value={val}
|
||||
onChange={(e) => setCount(cat, e.target.value)}
|
||||
className="w-20 text-center border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
||||
/>
|
||||
{!DEFAULT_CATS.includes(cat) && (
|
||||
<button type="button" onClick={() => removeCat(cat)}
|
||||
className="text-[10px] text-red-400 hover:text-red-600">remove</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add custom category inline */}
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="text-xs text-gray-400">+ category</span>
|
||||
<div className="flex gap-1">
|
||||
<input value={newCat} onChange={(e) => setNewCat(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') addCat(); }}
|
||||
placeholder="Name…"
|
||||
className="w-24 border border-dashed border-gray-300 rounded px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-indigo-400" />
|
||||
<button type="button" onClick={addCat}
|
||||
className="text-xs text-indigo-500 hover:text-indigo-700 px-1">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500 mb-3">
|
||||
<span>Total: <strong className="text-gray-800">{total}</strong></span>
|
||||
{successRate != null && (
|
||||
<span>Success rate: <strong className="text-gray-800">{(successRate * 100).toFixed(1)}%</strong></span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500 mb-2">{error}</p>}
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={save} disabled={saving}
|
||||
className="px-3 py-1.5 rounded-lg text-sm font-medium bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 transition-colors">
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button type="button" onClick={onClose}
|
||||
className="px-3 py-1.5 rounded-lg text-sm text-gray-500 hover:text-gray-700 border border-gray-200 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Past analysis row (expandable) ────────────────────────────────────────────
|
||||
|
||||
function AnalysisRow({ analysis, onDelete }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [full, setFull] = useState(null);
|
||||
const [loadingFull, setLoadingFull] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
async function expand() {
|
||||
if (expanded) { setExpanded(false); return; }
|
||||
setExpanded(true);
|
||||
if (full) return;
|
||||
setLoadingFull(true);
|
||||
try {
|
||||
const data = await analysesApi.get(analysis.id);
|
||||
setFull(data);
|
||||
} finally {
|
||||
setLoadingFull(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await analysesApi.delete(analysis.id);
|
||||
onDelete(analysis.id);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const counts = analysis.category_counts ?? {};
|
||||
const summary = Object.entries(counts)
|
||||
.sort(([, a], [, b]) => b.total - a.total)
|
||||
.map(([cat, d]) => `${cat}: ${d.total}`)
|
||||
.join(' · ');
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 cursor-pointer hover:bg-gray-50 transition-colors"
|
||||
onClick={expand}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="text-gray-400 text-xs">{expanded ? '▾' : '▸'}</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-gray-800 truncate">
|
||||
{analysis.file_name ?? 'Unnamed file'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{fmtDateTime(analysis.created_at)}
|
||||
{' · '}
|
||||
<span className="font-mono">{analysis.note_col}</span>
|
||||
{' · '}
|
||||
{analysis.total_note_rows} note rows
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 ml-4 shrink-0">
|
||||
<span className="text-xs text-gray-500 hidden sm:block">{summary}</span>
|
||||
<button type="button" onClick={(e) => { e.stopPropagation(); setConfirmDelete(true); }}
|
||||
className="text-xs text-red-400 hover:text-red-600 transition-colors">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t border-gray-100 px-4 py-4 bg-gray-50/40">
|
||||
{loadingFull && <p className="text-sm text-gray-400">Loading…</p>}
|
||||
{full && <AnalysisResultsView analysis={full} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={confirmDelete}
|
||||
onClose={() => setConfirmDelete(false)}
|
||||
onConfirm={handleDelete}
|
||||
loading={deleting}
|
||||
title="Delete Analysis"
|
||||
message="Remove this analysis record? The raw CSV is not affected."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Analysis results display (reused by both live and past) ───────────────────
|
||||
|
||||
export function AnalysisResultsView({ analysis }) {
|
||||
const { timestamp_col, note_col, total_note_rows,
|
||||
category_counts, consecutive_stats, runs } = analysis;
|
||||
const customCategories = [];
|
||||
|
||||
function colorsFor(cat) {
|
||||
const COLORS = {
|
||||
Success: { bg: 'bg-green-100', text: 'text-green-800', dot: 'bg-green-500', border: 'border-green-300' },
|
||||
Failure: { bg: 'bg-red-100', text: 'text-red-800', dot: 'bg-red-500', border: 'border-red-300' },
|
||||
Other: { bg: 'bg-gray-100', text: 'text-gray-700', dot: 'bg-gray-400', border: 'border-gray-300' },
|
||||
};
|
||||
const EXTRA = [
|
||||
{ bg: 'bg-indigo-100', text: 'text-indigo-800', dot: 'bg-indigo-500', border: 'border-indigo-300' },
|
||||
{ bg: 'bg-yellow-100', text: 'text-yellow-800', dot: 'bg-yellow-500', border: 'border-yellow-300' },
|
||||
{ bg: 'bg-purple-100', text: 'text-purple-800', dot: 'bg-purple-500', border: 'border-purple-300' },
|
||||
];
|
||||
if (COLORS[cat]) return COLORS[cat];
|
||||
const idx = customCategories.indexOf(cat) % EXTRA.length;
|
||||
return EXTRA[Math.max(0, idx)];
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5 text-sm">
|
||||
<p className="text-xs text-gray-400">
|
||||
{total_note_rows} note rows · timestamp: <span className="font-mono">{timestamp_col}</span>
|
||||
{' '}/ note: <span className="font-mono">{note_col}</span>
|
||||
</p>
|
||||
|
||||
{/* Category counts */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1.5">Category Counts</p>
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="bg-white border-b border-gray-200">
|
||||
<th className="text-left px-3 py-1.5 text-xs font-semibold text-gray-500">Category</th>
|
||||
<th className="text-left px-3 py-1.5 text-xs font-semibold text-gray-500">Breakdown</th>
|
||||
<th className="text-right px-3 py-1.5 text-xs font-semibold text-gray-500">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(category_counts ?? {})
|
||||
.sort(([, a], [, b]) => b.total - a.total)
|
||||
.map(([cat, data]) => {
|
||||
const c = colorsFor(cat);
|
||||
const bd = Object.entries(data.byNote ?? {})
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([n, cnt]) => `${n}: ${cnt}`).join(' · ');
|
||||
return (
|
||||
<tr key={cat} className="border-b border-gray-100">
|
||||
<td className="px-3 py-1.5">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ${c.bg} ${c.text}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${c.dot}`} />
|
||||
{cat}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-gray-500 font-mono text-xs">{bd}</td>
|
||||
<td className="px-3 py-1.5 text-right font-semibold text-gray-800">{data.total}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Consecutive stats */}
|
||||
{Object.keys(consecutive_stats ?? {}).length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1.5">Consecutive Runs</p>
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="bg-white border-b border-gray-200">
|
||||
<th className="text-left px-3 py-1.5 text-xs font-semibold text-gray-500">Category</th>
|
||||
<th className="text-right px-3 py-1.5 text-xs font-semibold text-gray-500">Runs</th>
|
||||
<th className="text-right px-3 py-1.5 text-xs font-semibold text-gray-500">Max</th>
|
||||
<th className="text-right px-3 py-1.5 text-xs font-semibold text-gray-500">Avg</th>
|
||||
<th className="text-left px-3 py-1.5 text-xs font-semibold text-gray-500">Lengths</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(consecutive_stats).map(([cat, s]) => {
|
||||
const c = colorsFor(cat);
|
||||
const summary = Object.entries(
|
||||
s.runLengths.reduce((acc, l) => { acc[l] = (acc[l] || 0) + 1; return acc; }, {})
|
||||
).sort(([a], [b]) => Number(a) - Number(b))
|
||||
.map(([l, cnt]) => `${l}×${cnt}`).join(', ');
|
||||
return (
|
||||
<tr key={cat} className="border-b border-gray-100">
|
||||
<td className="px-3 py-1.5">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ${c.bg} ${c.text}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${c.dot}`} />
|
||||
{cat}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right text-gray-700">{s.totalRuns}</td>
|
||||
<td className="px-3 py-1.5 text-right text-gray-700">{s.maxRun}</td>
|
||||
<td className="px-3 py-1.5 text-right text-gray-700">{s.avgRun.toFixed(2)}</td>
|
||||
<td className="px-3 py-1.5 text-gray-500 font-mono text-xs">{summary}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RunSequenceView runs={runs ?? []} sequence={analysis.sequence ?? []} sessionEndTs={analysis.session_end_ts ?? null} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Past analyses inline viewer ───────────────────────────────────────────────
|
||||
|
||||
function PastAnalysesViewer({ analyses, onDelete, onNewAnalysis }) {
|
||||
const [selectedId, setSelectedId] = useState(() => analyses[0]?.id ?? null);
|
||||
const [cache, setCache] = useState({});
|
||||
const [loadingId, setLoadingId] = useState(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
// Load full data whenever the selected id changes
|
||||
useEffect(() => {
|
||||
if (!selectedId || cache[selectedId]) return;
|
||||
setLoadingId(selectedId);
|
||||
analysesApi.get(selectedId)
|
||||
.then((data) => setCache((prev) => ({ ...prev, [selectedId]: data })))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoadingId(null));
|
||||
}, [selectedId]);
|
||||
|
||||
// If the list changes (new analysis added / deleted), keep selection valid
|
||||
useEffect(() => {
|
||||
if (!analyses.length) { setSelectedId(null); return; }
|
||||
if (!analyses.find((a) => a.id === selectedId)) setSelectedId(analyses[0].id);
|
||||
}, [analyses]);
|
||||
|
||||
async function handleDelete(id) {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await analysesApi.delete(id);
|
||||
setCache((prev) => { const n = { ...prev }; delete n[id]; return n; });
|
||||
onDelete(id);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setConfirmDelete(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (!analyses.length) return null;
|
||||
|
||||
const selected = analyses.find((a) => a.id === selectedId) ?? null;
|
||||
const fullData = selectedId ? (cache[selectedId] ?? null) : null;
|
||||
const isLoading = loadingId === selectedId;
|
||||
|
||||
const categorySummary = (a) =>
|
||||
Object.entries(a.category_counts ?? {})
|
||||
.sort(([, x], [, y]) => y.total - x.total)
|
||||
.map(([cat, d]) => `${cat}: ${d.total}`)
|
||||
.join(' · ');
|
||||
|
||||
return (
|
||||
<div className="mt-8 border-t border-gray-200 pt-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">
|
||||
Past Analyses ({analyses.length})
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Tab selector — scrollable if many */}
|
||||
{analyses.length > 1 && (
|
||||
<div className="flex gap-1.5 overflow-x-auto pb-2 mb-4 scrollbar-thin">
|
||||
{analyses.map((a) => {
|
||||
const active = a.id === selectedId;
|
||||
return (
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(a.id)}
|
||||
className={`shrink-0 flex flex-col items-start px-3 py-2 rounded-lg border text-left transition-colors
|
||||
${active
|
||||
? 'bg-indigo-50 border-indigo-300 text-indigo-800'
|
||||
: 'bg-white border-gray-200 text-gray-600 hover:border-gray-300 hover:bg-gray-50'}`}
|
||||
>
|
||||
<span className="text-xs font-medium truncate max-w-[160px]">
|
||||
{a.file_name ?? 'Unnamed file'}
|
||||
</span>
|
||||
<span className={`text-[10px] mt-0.5 ${active ? 'text-indigo-500' : 'text-gray-400'}`}>
|
||||
{fmtDateTime(a.created_at)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected analysis panel */}
|
||||
{selected && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
{/* Panel header */}
|
||||
<div className="flex items-start justify-between px-4 py-3 border-b border-gray-100 bg-gray-50/50">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-800">
|
||||
{selected.file_name ?? 'Unnamed file'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
{fmtDateTime(selected.created_at)}
|
||||
{' · '}
|
||||
<span className="font-mono">{selected.note_col}</span>
|
||||
{' · '}
|
||||
{selected.total_note_rows} note rows
|
||||
{categorySummary(selected) && (
|
||||
<span className="ml-2 text-gray-500">{categorySummary(selected)}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete(selected.id)}
|
||||
className="text-xs text-red-400 hover:text-red-600 transition-colors shrink-0 ml-4 mt-0.5"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Panel body */}
|
||||
<div className="px-4 py-4">
|
||||
{isLoading && (
|
||||
<p className="text-sm text-gray-400 py-4 text-center">Loading…</p>
|
||||
)}
|
||||
{!isLoading && fullData && <AnalysisResultsView analysis={fullData} />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={!!confirmDelete}
|
||||
onClose={() => setConfirmDelete(null)}
|
||||
onConfirm={() => handleDelete(confirmDelete)}
|
||||
loading={deleting}
|
||||
title="Delete Analysis"
|
||||
message="Remove this analysis record? The raw CSV is not affected."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function DailyStatusDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [status, setStatus] = useState(null);
|
||||
const [template, setTemplate] = useState([]);
|
||||
const [allStatuses, setAllStatuses] = useState([]);
|
||||
const [analyses, setAnalyses] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [showManualForm, setShowManualForm] = useState(false);
|
||||
|
||||
async function load(statusId = id) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const s = await dailyStatusesApi.get(statusId);
|
||||
const [tmpl, siblings, pastAnalyses] = await Promise.all([
|
||||
experimentsApi.getTemplate(s.animal.experiment_id),
|
||||
dailyStatusesApi.list(s.animal_id),
|
||||
analysesApi.listForStatus(statusId),
|
||||
]);
|
||||
setStatus(s);
|
||||
setTemplate(tmpl);
|
||||
setAllStatuses(siblings);
|
||||
setAnalyses(pastAnalyses);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, [id]);
|
||||
|
||||
async function handleDelete() {
|
||||
setDeleteLoading(true);
|
||||
try {
|
||||
await dailyStatusesApi.delete(status.id);
|
||||
navigate(`/animals/${status.animal_id}`, { replace: true });
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setShowDelete(false);
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="max-w-3xl mx-auto px-4 py-8 text-gray-400">Loading…</div>;
|
||||
if (error) return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-8">
|
||||
<Alert type="error" message={error} />
|
||||
<Button variant="secondary" className="mt-4" onClick={() => navigate(-1)}>← Back</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const animal = status.animal;
|
||||
const activeFields = template.filter((f) => f.active);
|
||||
|
||||
const currentIdx = allStatuses.findIndex((s) => s.id === status.id);
|
||||
const prevStatus = currentIdx > 0 ? allStatuses[currentIdx - 1] : null;
|
||||
const nextStatus = currentIdx < allStatuses.length - 1 ? allStatuses[currentIdx + 1] : null;
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-8">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="text-sm text-gray-500 mb-4">
|
||||
<Link to="/" className="hover:text-blue-600">Experiments</Link>
|
||||
<span className="mx-2">/</span>
|
||||
<Link to={`/experiments/${animal.experiment_id}`} className="hover:text-blue-600">Experiment</Link>
|
||||
<span className="mx-2">/</span>
|
||||
<Link to={`/animals/${animal.id}`} className="hover:text-blue-600">{animal.animal_name}</Link>
|
||||
<span className="mx-2">/</span>
|
||||
<span className="text-gray-900 font-medium">{fmtDate(status.date)}</span>
|
||||
</nav>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{fmtDate(status.date)}</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
{animal.animal_name}
|
||||
{animal.animal_id_string && <> · <span className="font-mono">{animal.animal_id_string}</span></>}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={() => setShowEdit(true)}>Edit entry</Button>
|
||||
</div>
|
||||
|
||||
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
|
||||
|
||||
{/* Field values */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 mb-6">
|
||||
{activeFields.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 italic">No template fields configured.</p>
|
||||
) : (
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-4">
|
||||
{activeFields.map((f) => {
|
||||
const value = getFieldValue(status, f);
|
||||
const label = f.unit ? `${f.label} (${f.unit})` : f.label;
|
||||
return (
|
||||
<div key={f.fieldId} className={f.type === 'textarea' ? 'sm:col-span-2' : ''}>
|
||||
<dt className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-0.5">{label}</dt>
|
||||
<dd className="text-gray-800 whitespace-pre-wrap break-words">
|
||||
{value ?? <span className="text-gray-300 italic">—</span>}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Analysis summary (pinned metrics) */}
|
||||
{status.analysis_summary ? (
|
||||
<AnalysisSummaryCard
|
||||
summary={status.analysis_summary}
|
||||
statusId={status.id}
|
||||
onSummaryUpdated={(summary) => setStatus((prev) => ({ ...prev, analysis_summary: summary }))}
|
||||
/>
|
||||
) : (
|
||||
<div className="mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowManualForm(true)}
|
||||
className="text-xs text-gray-400 hover:text-indigo-600 border border-dashed border-gray-200 hover:border-indigo-300 rounded-lg px-3 py-1.5 transition-colors"
|
||||
>
|
||||
Set metrics manually
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{showManualForm && !status.analysis_summary && (
|
||||
<ManualMetricsForm
|
||||
statusId={status.id}
|
||||
existing={null}
|
||||
onSaved={(summary) => {
|
||||
setStatus((prev) => ({ ...prev, analysis_summary: { ...summary, computed_at: new Date().toISOString() } }));
|
||||
setShowManualForm(false);
|
||||
}}
|
||||
onClose={() => setShowManualForm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Past analyses — inline viewer */}
|
||||
<PastAnalysesViewer
|
||||
analyses={analyses}
|
||||
onDelete={(deletedId) => setAnalyses((prev) => prev.filter((x) => x.id !== deletedId))}
|
||||
/>
|
||||
|
||||
{/* CSV Analysis — after past analyses */}
|
||||
<CsvAnalysis
|
||||
dailyStatusId={status.id}
|
||||
animal={status.animal}
|
||||
onSaved={(saved) => setAnalyses((prev) => [saved, ...prev])}
|
||||
onSummaryPushed={(summary) => setStatus((prev) => ({ ...prev, analysis_summary: { ...summary, computed_at: new Date().toISOString() } }))}
|
||||
/>
|
||||
|
||||
{/* Session file registry */}
|
||||
<FileDropZone
|
||||
rows={[{ animal, status }]}
|
||||
date={String(status.date).slice(0, 10)}
|
||||
/>
|
||||
|
||||
{/* Full modification history for this entry */}
|
||||
<AuditLogSection tableName="daily_statuses" recordId={status.id} />
|
||||
|
||||
{/* Prev / Next navigation */}
|
||||
<div className="flex justify-between items-center mt-8 mb-6">
|
||||
<div>
|
||||
{prevStatus && (
|
||||
<button type="button" onClick={() => navigate(`/daily-statuses/${prevStatus.id}`)}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 hover:underline">
|
||||
← {fmtDate(prevStatus.date)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Link to={`/animals/${animal.id}`} className="text-sm text-gray-500 hover:text-gray-800">
|
||||
All entries
|
||||
</Link>
|
||||
<div>
|
||||
{nextStatus && (
|
||||
<button type="button" onClick={() => navigate(`/daily-statuses/${nextStatus.id}`)}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 hover:underline">
|
||||
{fmtDate(nextStatus.date)} →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit modal */}
|
||||
<Modal isOpen={showEdit} onClose={() => setShowEdit(false)} title="Edit Daily Status" size="full">
|
||||
<DailyStatusForm
|
||||
existing={status}
|
||||
animalId={animal.id}
|
||||
experimentId={animal.experiment_id}
|
||||
template={template}
|
||||
onTemplateUpdate={setTemplate}
|
||||
onSuccess={(updated) => { setStatus({ ...updated, animal }); setShowEdit(false); }}
|
||||
onCancel={() => setShowEdit(false)}
|
||||
onRequestDelete={() => { setShowEdit(false); setShowDelete(true); }}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={showDelete}
|
||||
onClose={() => setShowDelete(false)}
|
||||
onConfirm={handleDelete}
|
||||
loading={deleteLoading}
|
||||
title="Delete Daily Status"
|
||||
message={`Delete the entry for ${fmtDate(status.date)}? This cannot be undone.`}
|
||||
confirmWord="delete"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -83,7 +83,7 @@ export default function Dashboard() {
|
||||
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
|
||||
|
||||
{loading && (
|
||||
<div className="text-center py-16 text-gray-400">Loading experiments…</div>
|
||||
<div className="text-center py-16 text-gray-400" aria-live="polite" aria-busy="true">Loading experiments…</div>
|
||||
)}
|
||||
|
||||
{!loading && experiments.length === 0 && (
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom';
|
||||
import { format } from 'date-fns';
|
||||
import {
|
||||
ResponsiveContainer, BarChart, Bar, XAxis, YAxis,
|
||||
CartesianGrid, Tooltip, Legend,
|
||||
} from 'recharts';
|
||||
import { experimentsApi, dailyStatusesApi } from '../api/client';
|
||||
import Button from '../components/ui/Button';
|
||||
import Modal from '../components/ui/Modal';
|
||||
import DailyStatusForm from '../components/DailyStatusForm';
|
||||
import Alert from '../components/ui/Alert';
|
||||
import FileDropZone from '../components/FileDropZone';
|
||||
|
||||
const BUILTIN_KEYS_SET = new Set(['experiment_description', 'vitals', 'treatment', 'notes']);
|
||||
|
||||
function getFieldValue(status, field) {
|
||||
if (field.builtin) return status[field.key];
|
||||
return status.custom_fields?.[field.fieldId];
|
||||
}
|
||||
|
||||
function cellText(value) {
|
||||
if (value === null || value === undefined || String(value).trim() === '') return null;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
// ── Inline cell editor ────────────────────────────────────────────────────────
|
||||
|
||||
function CellEdit({ value, field, saving, onSave, onCancel }) {
|
||||
const [val, setVal] = useState(value ?? '');
|
||||
const ref = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
ref.current?.focus();
|
||||
ref.current?.select();
|
||||
}, []);
|
||||
|
||||
function handleKey(e) {
|
||||
if (e.key === 'Escape') { e.preventDefault(); onCancel(); }
|
||||
if (e.key === 'Enter' && field.type !== 'textarea') { e.preventDefault(); onSave(val); }
|
||||
}
|
||||
|
||||
const cls = 'w-full text-sm border border-blue-400 rounded px-1.5 py-0.5 focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white disabled:opacity-60';
|
||||
|
||||
if (field.type === 'textarea') {
|
||||
return (
|
||||
<textarea
|
||||
ref={ref} value={val} rows={3} disabled={saving}
|
||||
onChange={(e) => setVal(e.target.value)}
|
||||
onBlur={() => onSave(val)}
|
||||
onKeyDown={handleKey}
|
||||
className={cls}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<input
|
||||
ref={ref} type="text" value={val} disabled={saving}
|
||||
onChange={(e) => setVal(e.target.value)}
|
||||
onBlur={() => onSave(val)}
|
||||
onKeyDown={handleKey}
|
||||
className={cls}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ExperimentDayView() {
|
||||
const { id, date } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [experiment, setExperiment] = useState(null);
|
||||
const [statuses, setStatuses] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [editStatus, setEditStatus] = useState(null);
|
||||
|
||||
// Inline cell editing
|
||||
const [editingCell, setEditingCell] = useState(null); // { statusId, fieldId }
|
||||
const [cellSaving, setCellSaving] = useState(false);
|
||||
const [cellError, setCellError] = useState(null);
|
||||
|
||||
// Day navigation
|
||||
const [dayList, setDayList] = useState([]);
|
||||
|
||||
const selectedField = searchParams.get('field') ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
experimentsApi.get(id),
|
||||
experimentsApi.getDayStatuses(id, date),
|
||||
])
|
||||
.then(([exp, s]) => {
|
||||
setExperiment(exp);
|
||||
setStatuses(s);
|
||||
})
|
||||
.catch((err) => setError(err.message ?? 'Failed to load'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id, date]);
|
||||
|
||||
// Fetch ordered list of days with data for this field, for prev/next nav
|
||||
useEffect(() => {
|
||||
if (!id || !selectedField) return;
|
||||
experimentsApi.getCalendar(id, selectedField)
|
||||
.then(({ days }) => setDayList(Object.keys(days).sort()))
|
||||
.catch(() => {});
|
||||
}, [id, selectedField]);
|
||||
|
||||
const dailyTemplate = useMemo(() => {
|
||||
if (!experiment) return [];
|
||||
return (experiment.template ?? []).filter((f) => f.active);
|
||||
}, [experiment]);
|
||||
|
||||
const animals = useMemo(() => experiment?.animals ?? [], [experiment]);
|
||||
|
||||
const statusByAnimal = useMemo(() => {
|
||||
const map = {};
|
||||
statuses.forEach((s) => { map[s.animal_id] = s; });
|
||||
return map;
|
||||
}, [statuses]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
return animals
|
||||
.map((animal) => ({ animal, status: statusByAnimal[animal.id] }))
|
||||
.filter(({ status }) => {
|
||||
if (!status) return false;
|
||||
if (!selectedField) return true;
|
||||
const isBuiltin = BUILTIN_KEYS_SET.has(selectedField);
|
||||
const val = isBuiltin ? status[selectedField] : status.custom_fields?.[selectedField];
|
||||
return val !== null && val !== undefined && String(val).trim() !== '';
|
||||
});
|
||||
}, [animals, statusByAnimal, selectedField]);
|
||||
|
||||
const barData = useMemo(() => {
|
||||
return rows
|
||||
.map(({ animal, status }) => {
|
||||
const summary = status?.analysis_summary;
|
||||
if (!summary || summary.total === 0) return null;
|
||||
return {
|
||||
name: animal.animal_name || animal.animal_id_string || animal.id.slice(0, 8),
|
||||
total: summary.total,
|
||||
success: summary.counts?.Success ?? 0,
|
||||
failure: summary.counts?.Failure ?? 0,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}, [rows]);
|
||||
|
||||
// Prev / next days with data
|
||||
const currentDayIdx = dayList.indexOf(date);
|
||||
const prevDay = currentDayIdx > 0 ? dayList[currentDayIdx - 1] : null;
|
||||
const nextDay = currentDayIdx < dayList.length - 1 ? dayList[currentDayIdx + 1] : null;
|
||||
|
||||
function dayNavUrl(d) {
|
||||
return `/experiments/${id}/day/${d}${selectedField ? `?field=${selectedField}` : ''}`;
|
||||
}
|
||||
|
||||
// Inline cell save
|
||||
async function saveCell(status, field, rawValue) {
|
||||
const value = rawValue?.trim() || null;
|
||||
// Skip save if unchanged
|
||||
const current = getFieldValue(status, field);
|
||||
const currentNorm = current == null ? null : String(current).trim() || null;
|
||||
if (value === currentNorm) { setEditingCell(null); return; }
|
||||
|
||||
setCellSaving(true);
|
||||
setCellError(null);
|
||||
try {
|
||||
let payload;
|
||||
if (field.builtin) {
|
||||
payload = { [field.key]: value };
|
||||
} else {
|
||||
payload = { custom_fields: { ...status.custom_fields, [field.fieldId]: value } };
|
||||
}
|
||||
const updated = await dailyStatusesApi.update(status.id, payload);
|
||||
setStatuses((prev) => prev.map((s) => s.id === updated.id ? updated : s));
|
||||
setEditingCell(null);
|
||||
} catch (err) {
|
||||
setCellError(err.message ?? 'Save failed');
|
||||
} finally {
|
||||
setCellSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const displayDate = date
|
||||
? format(new Date(date + 'T12:00:00'), 'MMMM d, yyyy')
|
||||
: date;
|
||||
|
||||
if (loading) {
|
||||
return <div className="max-w-5xl mx-auto px-4 py-10 text-center text-gray-400">Loading…</div>;
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 py-10">
|
||||
<Alert type="error" message={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 py-6">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="text-sm text-gray-400 mb-4 flex items-center gap-1.5">
|
||||
<Link to="/" className="hover:text-blue-600 transition-colors">Dashboard</Link>
|
||||
<span>/</span>
|
||||
<Link to={`/experiments/${id}`} className="hover:text-blue-600 transition-colors">
|
||||
{experiment?.title ?? id}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-gray-700 font-medium">{displayDate}</span>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-xl font-bold text-gray-900">{displayDate}</h1>
|
||||
<Button variant="secondary" onClick={() => navigate(`/experiments/${id}`)}>
|
||||
← Back to experiment
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{cellError && (
|
||||
<Alert type="error" message={cellError} onDismiss={() => setCellError(null)} className="mb-4" />
|
||||
)}
|
||||
|
||||
{/* Status table */}
|
||||
{rows.length === 0 ? (
|
||||
<div className="text-center py-16 border-2 border-dashed border-gray-200 rounded-xl text-gray-400">
|
||||
No data recorded for this day.
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden mb-8">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="text-sm border-collapse min-w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide whitespace-nowrap sticky left-0 bg-gray-50 z-10 border-r border-gray-200 min-w-[140px]">
|
||||
Subject
|
||||
</th>
|
||||
{dailyTemplate.map((f) => {
|
||||
const header = f.abbr
|
||||
? (f.unit ? `${f.abbr} (${f.unit})` : f.abbr)
|
||||
: (f.unit ? `${f.label} (${f.unit})` : f.label);
|
||||
return (
|
||||
<th
|
||||
key={f.fieldId}
|
||||
title={f.unit ? `${f.label} (${f.unit})` : f.label}
|
||||
className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide whitespace-nowrap max-w-[220px]"
|
||||
style={{ minWidth: Math.max(80, header.length * 7.5 + 24) }}
|
||||
>
|
||||
{header}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
<th className="sticky right-0 bg-gray-50 z-10 border-l border-gray-200 px-3 py-2 min-w-[80px]" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(({ animal, status }, i) => {
|
||||
const rowBg = i % 2 === 0 ? 'bg-white' : 'bg-gray-50/40';
|
||||
return (
|
||||
<tr key={animal.id} className={`border-b border-gray-100 last:border-0 ${rowBg}`}>
|
||||
{/* Subject name → daily status detail */}
|
||||
<td
|
||||
className={`px-3 py-2 font-medium whitespace-nowrap sticky left-0 z-10 border-r border-gray-100 ${rowBg} ${
|
||||
status
|
||||
? 'text-blue-600 cursor-pointer hover:text-blue-800 hover:bg-blue-50/40 transition-colors'
|
||||
: 'text-gray-500'
|
||||
}`}
|
||||
onClick={() => status && navigate(`/daily-statuses/${status.id}`)}
|
||||
>
|
||||
{animal.animal_name ?? animal.animal_id_string ?? animal.id.slice(0, 8)}
|
||||
{animal.animal_id_string && animal.animal_name && (
|
||||
<span className="ml-1.5 text-xs text-gray-400 font-normal">
|
||||
{animal.animal_id_string}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Field cells — click to edit inline */}
|
||||
{dailyTemplate.map((f) => {
|
||||
const isEditing = editingCell?.statusId === status?.id && editingCell?.fieldId === f.fieldId;
|
||||
const text = status ? cellText(getFieldValue(status, f)) : null;
|
||||
return (
|
||||
<td key={f.fieldId} className="px-3 py-1.5 text-gray-700 max-w-[220px] align-top">
|
||||
{isEditing ? (
|
||||
<CellEdit
|
||||
value={text}
|
||||
field={f}
|
||||
saving={cellSaving}
|
||||
onSave={(val) => saveCell(status, f, val)}
|
||||
onCancel={() => setEditingCell(null)}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="whitespace-pre-wrap break-words block rounded px-0.5 -mx-0.5 cursor-pointer hover:bg-blue-50 transition-colors"
|
||||
title="Click to edit"
|
||||
onClick={() => status && setEditingCell({ statusId: status.id, fieldId: f.fieldId })}
|
||||
>
|
||||
{text ?? <span className="text-gray-300">—</span>}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
|
||||
<td className={`px-2 py-1.5 sticky right-0 z-10 border-l border-gray-100 ${rowBg}`}>
|
||||
{status && (
|
||||
<Button size="sm" variant="secondary" onClick={() => setEditStatus(status)}>
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session metrics bar chart */}
|
||||
{barData.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-5 mb-8">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Session metrics</h3>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={barData} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} allowDecimals={false} />
|
||||
<Tooltip />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Bar dataKey="total" name="Total" fill="#94a3b8" radius={[3, 3, 0, 0]} />
|
||||
<Bar dataKey="success" name="Success" fill="#22c55e" radius={[3, 3, 0, 0]} />
|
||||
<Bar dataKey="failure" name="Failure" fill="#f87171" radius={[3, 3, 0, 0]} minPointSize={2} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prev / next day navigation */}
|
||||
{(prevDay || nextDay) && (
|
||||
<div className="flex justify-between items-center mt-2 mb-4">
|
||||
<div>
|
||||
{prevDay && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(dayNavUrl(prevDay))}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 hover:underline"
|
||||
>
|
||||
← {format(new Date(prevDay + 'T12:00:00'), 'MMMM d, yyyy')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{nextDay && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(dayNavUrl(nextDay))}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 hover:underline"
|
||||
>
|
||||
{format(new Date(nextDay + 'T12:00:00'), 'MMMM d, yyyy')} →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session file drop zone */}
|
||||
<FileDropZone rows={rows} date={date} />
|
||||
|
||||
{/* Edit modal (full row) */}
|
||||
{editStatus && (
|
||||
<Modal
|
||||
isOpen={!!editStatus}
|
||||
onClose={() => setEditStatus(null)}
|
||||
title="Edit daily status"
|
||||
size="lg"
|
||||
>
|
||||
<DailyStatusForm
|
||||
existing={editStatus}
|
||||
animalId={editStatus.animal_id}
|
||||
experimentId={id}
|
||||
template={dailyTemplate}
|
||||
onSuccess={(updated) => {
|
||||
setStatuses((prev) => prev.map((s) => s.id === updated.id ? updated : s));
|
||||
setEditStatus(null);
|
||||
}}
|
||||
onCancel={() => setEditStatus(null)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,53 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { experimentsApi, animalsApi } from '../api/client';
|
||||
import { ExperimentAnalysisCharts } from '../components/AnalysisCharts';
|
||||
import Button from '../components/ui/Button';
|
||||
import Modal from '../components/ui/Modal';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
import AnimalForm from '../components/AnimalForm';
|
||||
import TemplateEditor from '../components/TemplateEditor';
|
||||
import Alert from '../components/ui/Alert';
|
||||
import AuditLogSection from '../components/AuditLogSection';
|
||||
import ExperimentCalendar from '../components/ExperimentCalendar';
|
||||
import ExportDataModal from '../components/ExportDataModal';
|
||||
|
||||
function AnimalCardList({ animals, subjectTemplate, onNavigate, onEdit }) {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{animals.map((animal) => {
|
||||
const infoFields = subjectTemplate.filter((f) => f.active && animal.subject_info?.[f.fieldId] != null);
|
||||
return (
|
||||
<div
|
||||
key={animal.id}
|
||||
className="bg-white border border-gray-200 rounded-xl p-4 flex items-center justify-between hover:border-blue-300 hover:shadow-sm transition-all cursor-pointer group"
|
||||
onClick={() => onNavigate(animal)}
|
||||
>
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 group-hover:text-blue-700 transition-colors">
|
||||
{animal.animal_name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
ID: {animal.animal_id_string} · {animal._count?.daily_statuses ?? 0} daily status entries
|
||||
</div>
|
||||
{infoFields.length > 0 && (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 mt-1">
|
||||
{infoFields.map((f) => (
|
||||
<span key={f.fieldId} className="text-xs text-gray-400">
|
||||
<span className="font-medium text-gray-500">{f.label}:</span> {animal.subject_info[f.fieldId]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Button size="sm" variant="secondary" onClick={() => onEdit(animal)}>Edit</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExperimentDetail() {
|
||||
const { id } = useParams();
|
||||
@@ -20,19 +61,51 @@ export default function ExperimentDetail() {
|
||||
|
||||
const [showAddAnimal, setShowAddAnimal] = useState(false);
|
||||
const [editAnimal, setEditAnimal] = useState(null);
|
||||
const [deleteAnimal, setDeleteAnimal] = useState(null);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [showTemplate, setShowTemplate] = useState(false);
|
||||
const [showSubjectTemplate, setShowSubjectTemplate] = useState(false);
|
||||
const [showExport, setShowExport] = useState(false);
|
||||
const [subjectTemplate, setSubjectTemplate] = useState([]);
|
||||
const [dailyTemplate, setDailyTemplate] = useState([]);
|
||||
const [experimentStatuses, setExperimentStatuses] = useState([]);
|
||||
|
||||
const [displayMode, setDisplayMode] = useState(() => localStorage.getItem(`exp-display-mode-${id}`) ?? 'list');
|
||||
const [sortField, setSortField] = useState(() => localStorage.getItem(`exp-subject-sort-${id}`) ?? '__name__');
|
||||
const [sortDir, setSortDir] = useState(() => localStorage.getItem(`exp-subject-sort-dir-${id}`) ?? 'asc');
|
||||
const [groupField, setGroupField] = useState(() => localStorage.getItem(`exp-subject-group-${id}`) ?? '__name__');
|
||||
|
||||
function updateDisplayMode(mode) {
|
||||
setDisplayMode(mode);
|
||||
localStorage.setItem(`exp-display-mode-${id}`, mode);
|
||||
}
|
||||
function updateSort(field, dir) {
|
||||
setSortField(field);
|
||||
setSortDir(dir);
|
||||
localStorage.setItem(`exp-subject-sort-${id}`, field);
|
||||
localStorage.setItem(`exp-subject-sort-dir-${id}`, dir);
|
||||
}
|
||||
function updateGroupField(field) {
|
||||
setGroupField(field);
|
||||
localStorage.setItem(`exp-subject-group-${id}`, field);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [exp, anims] = await Promise.all([
|
||||
const [exp, anims, subjTmpl, dailyTmpl, expStatuses] = await Promise.all([
|
||||
experimentsApi.get(id),
|
||||
animalsApi.list(id),
|
||||
experimentsApi.getSubjectTemplate(id),
|
||||
experimentsApi.getTemplate(id),
|
||||
// Only fetch statuses that have saved analysis_summary — charts are the only consumer,
|
||||
// and they filter for analysis_summary.total != null anyway.
|
||||
experimentsApi.getDailyStatuses(id, { analysisOnly: true }),
|
||||
]);
|
||||
setExperiment(exp);
|
||||
setAnimals(anims);
|
||||
setSubjectTemplate(subjTmpl);
|
||||
setDailyTemplate(dailyTmpl);
|
||||
setExperimentStatuses(expStatuses);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@@ -54,22 +127,59 @@ export default function ExperimentDetail() {
|
||||
flash(editAnimal ? `Animal "${animal.animal_name}" updated.` : `Animal "${animal.animal_name}" added.`);
|
||||
}
|
||||
|
||||
async function handleDeleteAnimal() {
|
||||
setDeleteLoading(true);
|
||||
try {
|
||||
await animalsApi.delete(deleteAnimal.id);
|
||||
setDeleteAnimal(null);
|
||||
load();
|
||||
flash('Animal removed.');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setDeleteAnimal(null);
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
function handleTemplateSaved(updatedTemplate) {
|
||||
setShowTemplate(false);
|
||||
if (updatedTemplate) {
|
||||
// Update local experiment state so everything downstream re-renders
|
||||
setExperiment((prev) => ({ ...prev, template: updatedTemplate }));
|
||||
flash('Record template updated.');
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="max-w-5xl mx-auto px-4 py-8 text-gray-400">Loading…</div>;
|
||||
const sortedAnimals = useMemo(() => {
|
||||
const sorted = [...animals].sort((a, b) => {
|
||||
let av, bv;
|
||||
if (sortField === '__name__') {
|
||||
av = a.animal_name ?? '';
|
||||
bv = b.animal_name ?? '';
|
||||
} else if (sortField === '__id__') {
|
||||
av = a.animal_id_string ?? '';
|
||||
bv = b.animal_id_string ?? '';
|
||||
} else {
|
||||
av = a.subject_info?.[sortField] ?? '';
|
||||
bv = b.subject_info?.[sortField] ?? '';
|
||||
}
|
||||
// numeric-aware comparison
|
||||
const an = parseFloat(av), bn = parseFloat(bv);
|
||||
const cmp = (!isNaN(an) && !isNaN(bn)) ? an - bn : String(av).localeCompare(String(bv));
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
return sorted;
|
||||
}, [animals, sortField, sortDir, subjectTemplate]);
|
||||
|
||||
// Group mode: bucket sortedAnimals by the groupField value
|
||||
const groupedAnimals = useMemo(() => {
|
||||
if (displayMode !== 'group') return null;
|
||||
const getVal = (animal) => {
|
||||
if (groupField === '__name__') return animal.animal_name ?? '—';
|
||||
if (groupField === '__id__') return animal.animal_id_string ?? '—';
|
||||
const v = animal.subject_info?.[groupField];
|
||||
return v != null && v !== '' ? String(v) : '—';
|
||||
};
|
||||
const buckets = new Map();
|
||||
for (const animal of sortedAnimals) {
|
||||
const key = getVal(animal);
|
||||
if (!buckets.has(key)) buckets.set(key, []);
|
||||
buckets.get(key).push(animal);
|
||||
}
|
||||
// Sort bucket keys numerically-aware
|
||||
return [...buckets.entries()].sort(([a], [b]) => {
|
||||
const an = parseFloat(a), bn = parseFloat(b);
|
||||
return (!isNaN(an) && !isNaN(bn)) ? an - bn : a.localeCompare(b);
|
||||
});
|
||||
}, [sortedAnimals, displayMode, groupField]);
|
||||
|
||||
if (loading) return <div className="max-w-5xl mx-auto px-4 py-8 text-gray-400" aria-live="polite" aria-busy="true">Loading…</div>;
|
||||
if (error) return (
|
||||
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||
<Alert type="error" message={error} />
|
||||
@@ -93,65 +203,225 @@ export default function ExperimentDetail() {
|
||||
{animals.length} animal{animals.length !== 1 ? 's' : ''} enrolled
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowAddAnimal(true)}>+ Add Animal</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={() => setShowExport(true)}>Export data</Button>
|
||||
<Button onClick={() => setShowAddAnimal(true)}>+ Add Animal</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{successMsg && <Alert type="success" message={successMsg} />}
|
||||
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
|
||||
|
||||
{/* Template strips */}
|
||||
<div className="mb-5 space-y-2">
|
||||
{/* Daily record template */}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide w-24 shrink-0">Daily record</span>
|
||||
{(experiment.template ?? []).map((f) => (
|
||||
<span
|
||||
key={f.key}
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border ${
|
||||
f.active ? 'bg-blue-50 border-blue-200 text-blue-700' : 'bg-gray-100 border-gray-200 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{f.active ? null : <span title="hidden">◌</span>}
|
||||
{f.label}
|
||||
</span>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setShowTemplate(true)}
|
||||
className="inline-flex items-center gap-1 text-xs text-gray-400 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536M9 13l6.586-6.586a2 2 0 112.828 2.828L11.828 15.828A2 2 0 0110 16.414V19h2.586a2 2 0 001.414-.586l6.5-6.5" />
|
||||
</svg>
|
||||
{(experiment.template ?? []).length === 0 ? 'Set up' : 'Edit'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Subject info template */}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide w-24 shrink-0">Subject info</span>
|
||||
{subjectTemplate.map((f) => (
|
||||
<span
|
||||
key={f.key}
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border ${
|
||||
f.active ? 'bg-emerald-50 border-emerald-200 text-emerald-700' : 'bg-gray-100 border-gray-200 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{f.active ? null : <span title="hidden">◌</span>}
|
||||
{f.label}
|
||||
</span>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setShowSubjectTemplate(true)}
|
||||
className="inline-flex items-center gap-1 text-xs text-gray-400 hover:text-emerald-600 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536M9 13l6.586-6.586a2 2 0 112.828 2.828L11.828 15.828A2 2 0 0110 16.414V19h2.586a2 2 0 001.414-.586l6.5-6.5" />
|
||||
</svg>
|
||||
{subjectTemplate.length === 0 ? 'Set up' : 'Edit'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{animals.length === 0 ? (
|
||||
<div className="text-center py-16 border-2 border-dashed border-gray-200 rounded-xl">
|
||||
<p className="text-gray-400 mb-3">No animals enrolled in this experiment yet.</p>
|
||||
<Button onClick={() => setShowAddAnimal(true)}>+ Add First Animal</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{animals.map((animal) => (
|
||||
<div
|
||||
key={animal.id}
|
||||
className="bg-white border border-gray-200 rounded-xl p-4 flex items-center justify-between hover:border-blue-300 hover:shadow-sm transition-all cursor-pointer group"
|
||||
onClick={() => navigate(`/animals/${animal.id}`)}
|
||||
>
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 group-hover:text-blue-700 transition-colors">
|
||||
{animal.animal_name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
ID: {animal.animal_id_string} · {animal._count?.daily_statuses ?? 0} daily status entries
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Button size="sm" variant="secondary" onClick={() => setEditAnimal(animal)}>Edit</Button>
|
||||
<Button size="sm" variant="danger" onClick={() => setDeleteAnimal(animal)}>Remove</Button>
|
||||
</div>
|
||||
<>
|
||||
{/* Display controls */}
|
||||
<div className="flex items-center gap-3 mb-3 flex-wrap">
|
||||
{/* Mode toggle */}
|
||||
<div className="flex rounded border border-gray-200 overflow-hidden text-xs">
|
||||
{[['list', 'List'], ['group', 'Group']].map(([mode, label]) => (
|
||||
<button key={mode} type="button" onClick={() => updateDisplayMode(mode)}
|
||||
className={`px-3 py-1 border-l border-gray-200 first:border-l-0 transition-colors
|
||||
${displayMode === mode ? 'bg-gray-700 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{displayMode === 'list' && (
|
||||
<>
|
||||
<span className="text-xs text-gray-400">Sort by</span>
|
||||
<select value={sortField} onChange={(e) => updateSort(e.target.value, sortDir)}
|
||||
className="border border-gray-200 rounded px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400">
|
||||
<option value="__name__">Name</option>
|
||||
<option value="__id__">Subject ID</option>
|
||||
{subjectTemplate.filter((f) => f.active).map((f) => (
|
||||
<option key={f.fieldId} value={f.fieldId}>{f.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" onClick={() => updateSort(sortField, sortDir === 'asc' ? 'desc' : 'asc')}
|
||||
className="text-xs text-gray-500 hover:text-gray-800 border border-gray-200 rounded px-2 py-1 bg-white transition-colors">
|
||||
{sortDir === 'asc' ? '↑ Asc' : '↓ Desc'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{displayMode === 'group' && (
|
||||
<>
|
||||
<span className="text-xs text-gray-400">Group by</span>
|
||||
<select value={groupField} onChange={(e) => updateGroupField(e.target.value)}
|
||||
className="border border-gray-200 rounded px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400">
|
||||
<option value="__name__">Name</option>
|
||||
<option value="__id__">Subject ID</option>
|
||||
{subjectTemplate.filter((f) => f.active).map((f) => (
|
||||
<option key={f.fieldId} value={f.fieldId}>{f.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-xs text-gray-400 ml-1">· sorted within by</span>
|
||||
<select value={sortField} onChange={(e) => updateSort(e.target.value, sortDir)}
|
||||
className="border border-gray-200 rounded px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400">
|
||||
<option value="__name__">Name</option>
|
||||
<option value="__id__">Subject ID</option>
|
||||
{subjectTemplate.filter((f) => f.active).map((f) => (
|
||||
<option key={f.fieldId} value={f.fieldId}>{f.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" onClick={() => updateSort(sortField, sortDir === 'asc' ? 'desc' : 'asc')}
|
||||
className="text-xs text-gray-500 hover:text-gray-800 border border-gray-200 rounded px-2 py-1 bg-white transition-colors">
|
||||
{sortDir === 'asc' ? '↑ Asc' : '↓ Desc'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{displayMode === 'list' && (
|
||||
<AnimalCardList
|
||||
animals={sortedAnimals}
|
||||
subjectTemplate={subjectTemplate}
|
||||
onNavigate={(animal) => navigate(`/animals/${animal.id}`, { state: { template: experiment.template } })}
|
||||
onEdit={setEditAnimal}
|
||||
/>
|
||||
)}
|
||||
|
||||
{displayMode === 'group' && groupedAnimals && groupedAnimals.map(([groupValue, groupAnimals]) => {
|
||||
const groupLabel = (() => {
|
||||
if (groupField === '__name__' || groupField === '__id__') return groupValue;
|
||||
return subjectTemplate.find((f) => f.fieldId === groupField)?.label
|
||||
? `${subjectTemplate.find((f) => f.fieldId === groupField).label}: ${groupValue}`
|
||||
: groupValue;
|
||||
})();
|
||||
return (
|
||||
<div key={groupValue} className="mb-5">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">{groupLabel}</span>
|
||||
<span className="text-xs text-gray-400">({groupAnimals.length})</span>
|
||||
<div className="flex-1 border-t border-gray-100" />
|
||||
</div>
|
||||
<AnimalCardList
|
||||
animals={groupAnimals}
|
||||
subjectTemplate={subjectTemplate}
|
||||
onNavigate={(animal) => navigate(`/animals/${animal.id}`, { state: { template: experiment.template } })}
|
||||
onEdit={setEditAnimal}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
</>
|
||||
)}
|
||||
|
||||
<AuditLogSection tableName="animals" recordId={undefined} />
|
||||
<ExperimentAnalysisCharts
|
||||
animals={animals}
|
||||
experimentStatuses={experimentStatuses}
|
||||
subjectTemplate={subjectTemplate}
|
||||
dailyTemplate={dailyTemplate}
|
||||
plotConfig={experiment.plot_config}
|
||||
onPersistConfig={(cfg) => experimentsApi.savePlotConfig(id, cfg)}
|
||||
/>
|
||||
|
||||
{animals.length > 0 && (
|
||||
<ExperimentCalendar
|
||||
experimentId={id}
|
||||
template={dailyTemplate}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AuditLogSection tableName="animals" limit={5} />
|
||||
|
||||
{/* Template editor modal */}
|
||||
<Modal isOpen={showTemplate} onClose={() => setShowTemplate(false)} title="Daily Record Template" size="full">
|
||||
<TemplateEditor experimentId={id} onClose={handleTemplateSaved} />
|
||||
</Modal>
|
||||
|
||||
<Modal isOpen={showSubjectTemplate} onClose={() => setShowSubjectTemplate(false)} title="Subject Info Template" size="full">
|
||||
<TemplateEditor
|
||||
experimentId={id}
|
||||
loadFn={() => experimentsApi.getSubjectTemplate(id)}
|
||||
saveFn={(fields) => experimentsApi.updateSubjectTemplate(id, fields)}
|
||||
onClose={(updated) => {
|
||||
setShowSubjectTemplate(false);
|
||||
if (updated) { setSubjectTemplate(updated); flash('Subject info template updated.'); }
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal isOpen={showAddAnimal} onClose={() => setShowAddAnimal(false)} title="Add Animal">
|
||||
<AnimalForm experimentId={id} onSuccess={handleAnimalSaved} onCancel={() => setShowAddAnimal(false)} />
|
||||
</Modal>
|
||||
|
||||
<Modal isOpen={!!editAnimal} onClose={() => setEditAnimal(null)} title="Edit Animal">
|
||||
<AnimalForm
|
||||
existing={editAnimal}
|
||||
<AnimalForm existing={editAnimal} experimentId={id} onSuccess={handleAnimalSaved} onCancel={() => setEditAnimal(null)} />
|
||||
</Modal>
|
||||
|
||||
<Modal isOpen={showExport} onClose={() => setShowExport(false)} title="Export data" size="md">
|
||||
<ExportDataModal
|
||||
experimentId={id}
|
||||
onSuccess={handleAnimalSaved}
|
||||
onCancel={() => setEditAnimal(null)}
|
||||
experimentTitle={experiment.title}
|
||||
dailyTemplate={dailyTemplate}
|
||||
animals={animals}
|
||||
subjectTemplate={subjectTemplate}
|
||||
groupField={localStorage.getItem(`exp-subject-group-${id}`) ?? '__none__'}
|
||||
onClose={() => setShowExport(false)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={!!deleteAnimal}
|
||||
onClose={() => setDeleteAnimal(null)}
|
||||
onConfirm={handleDeleteAnimal}
|
||||
loading={deleteLoading}
|
||||
title="Remove Animal"
|
||||
message={`Remove "${deleteAnimal?.animal_name}" and all its daily statuses from this experiment?`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,5 +15,6 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
},
|
||||
safelist: ['col-span-1', 'col-span-2', 'col-span-3', 'col-span-4', 'col-span-5', 'col-span-6', 'col-span-8', 'col-span-9', 'col-span-12'],
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import AnimalForm from '../src/components/AnimalForm';
|
||||
import * as client from '../src/api/client';
|
||||
|
||||
jest.mock('../src/api/client', () => ({
|
||||
animalsApi: {
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const EXP_ID = 'exp-001';
|
||||
const onSuccess = jest.fn();
|
||||
const onCancel = jest.fn();
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
describe('AnimalForm', () => {
|
||||
it('renders Animal ID and Animal Name fields', () => {
|
||||
render(<AnimalForm experimentId={EXP_ID} onSuccess={onSuccess} onCancel={onCancel} />);
|
||||
expect(screen.getByLabelText(/animal id/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/animal name/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows required field errors on empty submit', async () => {
|
||||
render(<AnimalForm experimentId={EXP_ID} onSuccess={onSuccess} onCancel={onCancel} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /add animal/i }));
|
||||
expect(await screen.findAllByRole('alert')).toHaveLength(2);
|
||||
expect(client.animalsApi.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls create with correct payload', async () => {
|
||||
const animal = { id: 'a-1', animal_id_string: 'M-001', animal_name: 'Whiskers', experiment_id: EXP_ID };
|
||||
client.animalsApi.create.mockResolvedValue(animal);
|
||||
|
||||
render(<AnimalForm experimentId={EXP_ID} onSuccess={onSuccess} onCancel={onCancel} />);
|
||||
await userEvent.type(screen.getByLabelText(/animal id/i), 'M-001');
|
||||
await userEvent.type(screen.getByLabelText(/animal name/i), 'Whiskers');
|
||||
fireEvent.click(screen.getByRole('button', { name: /add animal/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(client.animalsApi.create).toHaveBeenCalledWith({
|
||||
animal_id_string: 'M-001',
|
||||
animal_name: 'Whiskers',
|
||||
experiment_id: EXP_ID,
|
||||
})
|
||||
);
|
||||
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(animal));
|
||||
});
|
||||
|
||||
it('pre-fills fields for existing animal and uses update API', async () => {
|
||||
const existing = { id: 'a-1', animal_id_string: 'M-001', animal_name: 'Whiskers' };
|
||||
const updated = { ...existing, animal_name: 'Mr Whiskers' };
|
||||
client.animalsApi.update.mockResolvedValue(updated);
|
||||
|
||||
render(<AnimalForm existing={existing} experimentId={EXP_ID} onSuccess={onSuccess} onCancel={onCancel} />);
|
||||
expect(screen.getByDisplayValue('Whiskers')).toBeInTheDocument();
|
||||
|
||||
const nameInput = screen.getByLabelText(/animal name/i);
|
||||
await userEvent.clear(nameInput);
|
||||
await userEvent.type(nameInput, 'Mr Whiskers');
|
||||
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(client.animalsApi.update).toHaveBeenCalledWith('a-1', {
|
||||
animal_id_string: 'M-001',
|
||||
animal_name: 'Mr Whiskers',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import Button from '../src/components/ui/Button';
|
||||
|
||||
describe('Button component', () => {
|
||||
it('renders with children text', () => {
|
||||
render(<Button>Click me</Button>);
|
||||
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClick when clicked', () => {
|
||||
const onClick = jest.fn();
|
||||
render(<Button onClick={onClick}>Click</Button>);
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('is disabled when disabled prop is true', () => {
|
||||
render(<Button disabled>Submit</Button>);
|
||||
expect(screen.getByRole('button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('is disabled and shows spinner when loading', () => {
|
||||
render(<Button loading>Submit</Button>);
|
||||
const btn = screen.getByRole('button');
|
||||
expect(btn).toBeDisabled();
|
||||
expect(btn.querySelector('svg')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders danger variant', () => {
|
||||
render(<Button variant="danger">Delete</Button>);
|
||||
const btn = screen.getByRole('button');
|
||||
expect(btn.className).toContain('bg-red-600');
|
||||
});
|
||||
|
||||
it('does not call onClick when disabled', () => {
|
||||
const onClick = jest.fn();
|
||||
render(<Button disabled onClick={onClick}>Click</Button>);
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import DailyStatusForm from '../src/components/DailyStatusForm';
|
||||
import * as client from '../src/api/client';
|
||||
|
||||
jest.mock('../src/api/client', () => ({
|
||||
dailyStatusesApi: {
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
experimentsApi: {
|
||||
updateTemplate: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const ANIMAL_ID = 'animal-001';
|
||||
const onSuccess = jest.fn();
|
||||
const onCancel = jest.fn();
|
||||
|
||||
const BUILTIN_TEMPLATE = [
|
||||
{ fieldId: 'f1', key: 'vitals', label: 'Vitals', type: 'textarea', builtin: true, active: true, width: 100 },
|
||||
{ fieldId: 'f2', key: 'treatment', label: 'Treatment', type: 'textarea', builtin: true, active: true, width: 100 },
|
||||
{ fieldId: 'f3', key: 'notes', label: 'Notes', type: 'textarea', builtin: true, active: true, width: 100 },
|
||||
{ fieldId: 'f4', key: 'experiment_description', label: 'Experiment Description', type: 'textarea', builtin: true, active: true, width: 100 },
|
||||
];
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
describe('DailyStatusForm', () => {
|
||||
it('renders date field (required) and optional fields', () => {
|
||||
render(<DailyStatusForm animalId={ANIMAL_ID} template={BUILTIN_TEMPLATE} onSuccess={onSuccess} onCancel={onCancel} />);
|
||||
expect(screen.getByLabelText(/date/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/vitals/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/treatment/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/notes/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/experiment description/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('pre-fills date with today by default', () => {
|
||||
render(<DailyStatusForm animalId={ANIMAL_ID} template={BUILTIN_TEMPLATE} onSuccess={onSuccess} onCancel={onCancel} />);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
expect(screen.getByLabelText(/date/i)).toHaveValue(today);
|
||||
});
|
||||
|
||||
it('shows error when date is cleared', async () => {
|
||||
render(<DailyStatusForm animalId={ANIMAL_ID} template={BUILTIN_TEMPLATE} onSuccess={onSuccess} onCancel={onCancel} />);
|
||||
const dateInput = screen.getByLabelText(/date/i);
|
||||
await userEvent.clear(dateInput);
|
||||
fireEvent.click(screen.getByRole('button', { name: /log status/i }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(/date is required/i);
|
||||
expect(client.dailyStatusesApi.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls create with correct payload including animal_id', async () => {
|
||||
const status = { id: 's-1', animal_id: ANIMAL_ID, date: '2024-06-01', vitals: 'HR 72' };
|
||||
client.dailyStatusesApi.create.mockResolvedValue(status);
|
||||
|
||||
render(<DailyStatusForm animalId={ANIMAL_ID} template={BUILTIN_TEMPLATE} onSuccess={onSuccess} onCancel={onCancel} />);
|
||||
|
||||
const dateInput = screen.getByLabelText(/date/i);
|
||||
fireEvent.change(dateInput, { target: { value: '2024-06-01' } });
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/vitals/i), 'HR 72');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /log status/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(client.dailyStatusesApi.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ animal_id: ANIMAL_ID, vitals: 'HR 72' })
|
||||
)
|
||||
);
|
||||
await waitFor(() => expect(onSuccess).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('shows "Save Changes" and pre-fills fields for existing status', () => {
|
||||
const existing = {
|
||||
id: 's-1',
|
||||
date: '2024-06-01T00:00:00.000Z',
|
||||
vitals: 'HR 72',
|
||||
treatment: 'Control',
|
||||
notes: 'Normal',
|
||||
experiment_description: 'Baseline',
|
||||
};
|
||||
render(
|
||||
<DailyStatusForm
|
||||
existing={existing}
|
||||
template={BUILTIN_TEMPLATE}
|
||||
animalId={ANIMAL_ID}
|
||||
onSuccess={onSuccess}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole('button', { name: /save changes/i })).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('HR 72')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onCancel when Cancel is clicked', () => {
|
||||
render(<DailyStatusForm animalId={ANIMAL_ID} template={BUILTIN_TEMPLATE} onSuccess={onSuccess} onCancel={onCancel} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
|
||||
// recharts uses ResizeObserver (absent in jsdom); stub all used pieces as passthroughs.
|
||||
// Coverage boundary: with recharts mocked out, these tests cover only the sidebar
|
||||
// integration path (toggle, checkbox state, persistence). The legend onClick/hover
|
||||
// wiring, line hide/strokeWidth, and tooltip ordering are exercised by the pure-helper
|
||||
// unit tests (crossSubjectChart.test.js) and SubjectSidebar.test.jsx.
|
||||
jest.mock('recharts', () => {
|
||||
const Pass = ({ children }) => <div>{children}</div>;
|
||||
return {
|
||||
ResponsiveContainer: Pass, LineChart: Pass, Line: () => null,
|
||||
XAxis: () => null, YAxis: () => null, CartesianGrid: () => null,
|
||||
Tooltip: () => null, Legend: () => null,
|
||||
};
|
||||
});
|
||||
|
||||
import { ExperimentAnalysisCharts } from '../src/components/AnalysisCharts';
|
||||
|
||||
const ANIMALS = [
|
||||
{ id: 'a1', animal_name: 'Mouse-A1', subject_info: { grp: 'Group A' } },
|
||||
{ id: 'b1', animal_name: 'Mouse-B1', subject_info: { grp: 'Group B' } },
|
||||
];
|
||||
const STATUSES = [
|
||||
{ animal_id: 'a1', date: '2026-01-01', analysis_summary: { total: 10, success_rate: 0.5, counts: { Success: 5 } } },
|
||||
{ animal_id: 'b1', date: '2026-01-01', analysis_summary: { total: 8, success_rate: 0.25, counts: { Success: 2 } } },
|
||||
];
|
||||
const SUBJECT_TEMPLATE = [{ fieldId: 'grp', label: 'Group', active: true }];
|
||||
const DAILY_TEMPLATE = [];
|
||||
|
||||
function renderChart() {
|
||||
render(
|
||||
<ExperimentAnalysisCharts
|
||||
animals={ANIMALS}
|
||||
experimentStatuses={STATUSES}
|
||||
subjectTemplate={SUBJECT_TEMPLATE}
|
||||
dailyTemplate={DAILY_TEMPLATE}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it('toggles the subject sidebar open and closed', () => {
|
||||
renderChart();
|
||||
expect(screen.queryByText('Subjects')).toBeNull();
|
||||
fireEvent.click(screen.getByText('▸ Subjects'));
|
||||
expect(screen.getByText('Subjects')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Mouse-A1')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Mouse-B1')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('◂ Subjects'));
|
||||
expect(screen.queryByText('Subjects')).toBeNull();
|
||||
});
|
||||
|
||||
it('unchecking a subject in the sidebar persists across re-render', () => {
|
||||
renderChart();
|
||||
fireEvent.click(screen.getByText('▸ Subjects'));
|
||||
const cb = screen.getByLabelText('Mouse-A1');
|
||||
expect(cb.checked).toBe(true);
|
||||
fireEvent.click(cb);
|
||||
expect(screen.getByLabelText('Mouse-A1').checked).toBe(false);
|
||||
expect(screen.getByText('Show all')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hydrates hidden subjects and sidebar-open from plotConfig', () => {
|
||||
render(
|
||||
<ExperimentAnalysisCharts
|
||||
animals={ANIMALS}
|
||||
experimentStatuses={STATUSES}
|
||||
subjectTemplate={SUBJECT_TEMPLATE}
|
||||
dailyTemplate={DAILY_TEMPLATE}
|
||||
plotConfig={{ hiddenSubjects: ['a1'], sidebarOpen: true }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByLabelText('Mouse-A1').checked).toBe(false);
|
||||
expect(screen.getByLabelText('Mouse-B1').checked).toBe(true);
|
||||
});
|
||||
|
||||
describe('debounced auto-save', () => {
|
||||
beforeEach(() => jest.useFakeTimers());
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
it('does not call onPersistConfig on initial render', () => {
|
||||
const onPersist = jest.fn();
|
||||
render(
|
||||
<ExperimentAnalysisCharts
|
||||
animals={ANIMALS} experimentStatuses={STATUSES}
|
||||
subjectTemplate={SUBJECT_TEMPLATE} dailyTemplate={DAILY_TEMPLATE}
|
||||
plotConfig={null} onPersistConfig={onPersist}
|
||||
/>,
|
||||
);
|
||||
act(() => { jest.advanceTimersByTime(1500); });
|
||||
expect(onPersist).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onPersistConfig ~1s after a change, with the updated config', () => {
|
||||
const onPersist = jest.fn().mockResolvedValue({});
|
||||
render(
|
||||
<ExperimentAnalysisCharts
|
||||
animals={ANIMALS} experimentStatuses={STATUSES}
|
||||
subjectTemplate={SUBJECT_TEMPLATE} dailyTemplate={DAILY_TEMPLATE}
|
||||
plotConfig={{ sidebarOpen: true }} onPersistConfig={onPersist}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText('Mouse-A1'));
|
||||
expect(onPersist).not.toHaveBeenCalled();
|
||||
act(() => { jest.advanceTimersByTime(1100); });
|
||||
expect(onPersist).toHaveBeenCalledTimes(1);
|
||||
expect(onPersist.mock.calls[0][0].hiddenSubjects).toContain('a1');
|
||||
});
|
||||
|
||||
it('does not save and clears the saving hint when a change is reverted before the debounce fires', () => {
|
||||
const onPersist = jest.fn().mockResolvedValue({});
|
||||
render(
|
||||
<ExperimentAnalysisCharts
|
||||
animals={ANIMALS} experimentStatuses={STATUSES}
|
||||
subjectTemplate={SUBJECT_TEMPLATE} dailyTemplate={DAILY_TEMPLATE}
|
||||
plotConfig={{ sidebarOpen: true }} onPersistConfig={onPersist}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText('Mouse-A1')); // hide a1
|
||||
expect(screen.getByText('Saving…')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByLabelText('Mouse-A1')); // revert before the 1s timer
|
||||
act(() => { jest.advanceTimersByTime(1100); });
|
||||
expect(onPersist).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText('Saving…')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import ExperimentCalendar from '../src/components/ExperimentCalendar';
|
||||
|
||||
const mockNavigate = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
useNavigate: () => mockNavigate,
|
||||
}));
|
||||
|
||||
const EXP_ID = 'aaaaaaaa-0000-0000-0000-000000000001';
|
||||
|
||||
const TEMPLATE = [
|
||||
{ fieldId: '00000000-0000-0000-0000-000000000002', key: 'vitals', label: 'Vitals', active: true, builtin: true },
|
||||
{ fieldId: 'ww000000-0000-0000-0000-000000000099', key: 'weights', label: 'Weights', active: true, builtin: false },
|
||||
{ fieldId: 'zz000000-0000-0000-0000-000000000001', key: 'notes', label: 'Notes', active: false, builtin: true },
|
||||
];
|
||||
|
||||
function todayStr() {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function makeStatus(animalId, date, customFields = {}, vitals = null) {
|
||||
return {
|
||||
id: `s-${animalId}-${date}`,
|
||||
animal_id: animalId,
|
||||
date: `${date}T00:00:00.000Z`,
|
||||
experiment_description: null,
|
||||
vitals,
|
||||
treatment: null,
|
||||
notes: null,
|
||||
custom_fields: customFields,
|
||||
analysis_summary: null,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── Rendering ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ExperimentCalendar rendering', () => {
|
||||
it('renders the current month and year', () => {
|
||||
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} />);
|
||||
const now = new Date();
|
||||
const monthLabel = now.toLocaleString('en-US', { month: 'long', year: 'numeric' });
|
||||
expect(screen.getByText(monthLabel)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders day-of-week headers', () => {
|
||||
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} />);
|
||||
for (const d of ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']) {
|
||||
expect(screen.getByText(d)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders the field selector with active template fields only', () => {
|
||||
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} />);
|
||||
const options = screen.getAllByRole('option').map((o) => o.textContent);
|
||||
expect(options).toContain('Vitals');
|
||||
expect(options).toContain('Weights');
|
||||
expect(options).not.toContain('Notes'); // inactive
|
||||
});
|
||||
});
|
||||
|
||||
// ── Default field selection ────────────────────────────────────────────────────
|
||||
|
||||
describe('default field selection', () => {
|
||||
it('defaults to the field whose label contains "weight"', () => {
|
||||
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} />);
|
||||
expect(screen.getByTestId('field-select')).toHaveValue('ww000000-0000-0000-0000-000000000099');
|
||||
});
|
||||
|
||||
it('defaults to first field if none contains "weight"', () => {
|
||||
const noWeightTemplate = [
|
||||
{ fieldId: 'ff000000-0000-0000-0000-000000000001', key: 'treatment', label: 'Treatment', active: true, builtin: true },
|
||||
{ fieldId: 'ff000000-0000-0000-0000-000000000002', key: 'vitals', label: 'Vitals', active: true, builtin: true },
|
||||
];
|
||||
render(<ExperimentCalendar experimentId={EXP_ID} template={noWeightTemplate} allStatuses={[]} />);
|
||||
expect(screen.getByTestId('field-select')).toHaveValue('treatment');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Count badges (computed from allStatuses) ───────────────────────────────────
|
||||
|
||||
describe('count badges', () => {
|
||||
it('shows a count badge for a day where the selected field is non-empty', () => {
|
||||
const dateStr = todayStr();
|
||||
const statuses = [
|
||||
makeStatus('a1', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }),
|
||||
makeStatus('a2', dateStr, { 'ww000000-0000-0000-0000-000000000099': '310' }),
|
||||
];
|
||||
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} />);
|
||||
expect(screen.getByTestId(`count-${dateStr}`)).toHaveTextContent('2');
|
||||
});
|
||||
|
||||
it('does not show count badge on days with no data', () => {
|
||||
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} />);
|
||||
expect(screen.queryByTestId(`count-${todayStr()}`)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a count badge for builtin field (vitals)', () => {
|
||||
const dateStr = todayStr();
|
||||
const statuses = [makeStatus('a1', dateStr, {}, 'HR 72')];
|
||||
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} />);
|
||||
fireEvent.change(screen.getByTestId('field-select'), { target: { value: 'vitals' } });
|
||||
expect(screen.getByTestId(`count-${dateStr}`)).toHaveTextContent('1');
|
||||
});
|
||||
|
||||
it('recomputes counts after field change', () => {
|
||||
const dateStr = todayStr();
|
||||
const statuses = [makeStatus('a1', dateStr, {}, 'HR 72')];
|
||||
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} />);
|
||||
// Default is weights — no badge
|
||||
expect(screen.queryByTestId(`count-${dateStr}`)).not.toBeInTheDocument();
|
||||
// Switch to vitals — badge appears
|
||||
fireEvent.change(screen.getByTestId('field-select'), { target: { value: 'vitals' } });
|
||||
expect(screen.getByTestId(`count-${dateStr}`)).toHaveTextContent('1');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Month navigation ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('month navigation', () => {
|
||||
it('moves to the previous month on ‹ click', () => {
|
||||
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} />);
|
||||
const now = new Date();
|
||||
const prev = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
const prevLabel = prev.toLocaleString('en-US', { month: 'long', year: 'numeric' });
|
||||
fireEvent.click(screen.getByLabelText('Previous month'));
|
||||
expect(screen.getByText(prevLabel)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('moves to the next month on › click', () => {
|
||||
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} />);
|
||||
const now = new Date();
|
||||
const next = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
const nextLabel = next.toLocaleString('en-US', { month: 'long', year: 'numeric' });
|
||||
fireEvent.click(screen.getByLabelText('Next month'));
|
||||
expect(screen.getByText(nextLabel)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Day click navigation ──────────────────────────────────────────────────────
|
||||
|
||||
describe('day click navigation', () => {
|
||||
it('navigates to the day view with field param when a day with data is clicked', () => {
|
||||
const dateStr = todayStr();
|
||||
const statuses = [
|
||||
makeStatus('a1', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }),
|
||||
];
|
||||
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} />);
|
||||
fireEvent.click(screen.getByTestId(`day-${dateStr}`));
|
||||
expect(mockNavigate).toHaveBeenCalledWith(
|
||||
`/experiments/${EXP_ID}/day/${dateStr}?field=ww000000-0000-0000-0000-000000000099`,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not navigate when clicking a day without data (button is disabled)', () => {
|
||||
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} />);
|
||||
const dayBtn = screen.getByTestId(`day-${todayStr()}`);
|
||||
expect(dayBtn).toBeDisabled();
|
||||
fireEvent.click(dayBtn);
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('includes the currently selected field in the navigation URL', () => {
|
||||
const dateStr = todayStr();
|
||||
const statuses = [makeStatus('a1', dateStr, {}, 'HR 72')];
|
||||
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} />);
|
||||
// Switch to vitals field first
|
||||
fireEvent.change(screen.getByTestId('field-select'), { target: { value: 'vitals' } });
|
||||
fireEvent.click(screen.getByTestId(`day-${dateStr}`));
|
||||
expect(mockNavigate).toHaveBeenCalledWith(
|
||||
`/experiments/${EXP_ID}/day/${dateStr}?field=vitals`,
|
||||
);
|
||||
});
|
||||
|
||||
it('navigates with no field param when selectedField is null (no active fields)', () => {
|
||||
const emptyTemplate = [];
|
||||
render(<ExperimentCalendar experimentId={EXP_ID} template={emptyTemplate} allStatuses={[]} />);
|
||||
// No days have data with empty template, so nothing to click — just verify no crash
|
||||
expect(screen.queryByTestId(`count-${todayStr()}`)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,639 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
|
||||
import ExperimentDayView from '../src/pages/ExperimentDayView';
|
||||
|
||||
// ── Mocks ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockNavigate = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
useParams: jest.fn(),
|
||||
useSearchParams: jest.fn(),
|
||||
useNavigate: () => mockNavigate,
|
||||
Link: ({ to, children, ...props }) => <a href={to} {...props}>{children}</a>,
|
||||
}));
|
||||
const { useParams, useSearchParams } = require('react-router-dom');
|
||||
const { experimentsApi, dailyStatusesApi } = require('../src/api/client');
|
||||
|
||||
jest.mock('../src/api/client', () => ({
|
||||
experimentsApi: {
|
||||
get: jest.fn(),
|
||||
getDayStatuses: jest.fn(),
|
||||
getCalendar: jest.fn(),
|
||||
},
|
||||
dailyStatusesApi: {
|
||||
update: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// DailyStatusForm is a complex form component; stub it to keep tests focused
|
||||
jest.mock('../src/components/DailyStatusForm', () =>
|
||||
function MockDailyStatusForm({ onSuccess, onCancel }) {
|
||||
return (
|
||||
<div data-testid="daily-status-form">
|
||||
<button onClick={() => onSuccess({ id: 'updated-id' })}>Submit</button>
|
||||
<button onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// recharts uses ResizeObserver which is not available in jsdom
|
||||
jest.mock('recharts', () => ({
|
||||
ResponsiveContainer: ({ children }) => <div>{children}</div>,
|
||||
BarChart: ({ children }) => <div data-testid="bar-chart">{children}</div>,
|
||||
Bar: () => null,
|
||||
XAxis: () => null,
|
||||
YAxis: () => null,
|
||||
CartesianGrid: () => null,
|
||||
Tooltip: () => null,
|
||||
Legend: () => null,
|
||||
}));
|
||||
|
||||
// ── Fixtures ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const EXP_ID = 'exp-0001-0000-0000-0000-000000000001';
|
||||
const DATE_STR = '2026-04-23';
|
||||
const FIELD_ID = 'ww000000-0000-0000-0000-000000000099';
|
||||
|
||||
const TEMPLATE = [
|
||||
{ fieldId: '00000000-0000-0000-0000-000000000001', key: 'vitals', label: 'Vitals', type: 'text', builtin: true, active: true },
|
||||
{ fieldId: FIELD_ID, key: 'weight', label: 'Weight', type: 'text', builtin: false, active: true },
|
||||
{ fieldId: 'zz000000-0000-0000-0000-000000000001', key: 'notes', label: 'Notes', type: 'textarea', builtin: true, active: false },
|
||||
];
|
||||
|
||||
const ANIMALS = [
|
||||
{ id: 'a1000000-0000-0000-0000-000000000001', animal_name: 'Rat A', animal_id_string: 'R001' },
|
||||
{ id: 'a2000000-0000-0000-0000-000000000002', animal_name: 'Rat B', animal_id_string: 'R002' },
|
||||
{ id: 'a3000000-0000-0000-0000-000000000003', animal_name: 'Rat C', animal_id_string: 'R003' },
|
||||
];
|
||||
|
||||
const EXPERIMENT = {
|
||||
id: EXP_ID,
|
||||
title: 'Reach Task Study',
|
||||
template: TEMPLATE,
|
||||
animals: ANIMALS,
|
||||
};
|
||||
|
||||
function makeStatus(animalId, overrides = {}) {
|
||||
return {
|
||||
id: `status-${animalId}`,
|
||||
animal_id: animalId,
|
||||
date: `${DATE_STR}T00:00:00.000Z`,
|
||||
experiment_description: null,
|
||||
vitals: null,
|
||||
treatment: null,
|
||||
notes: null,
|
||||
custom_fields: {},
|
||||
analysis_summary: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeSearchParams(field = FIELD_ID) {
|
||||
const params = new URLSearchParams(field ? `field=${field}` : '');
|
||||
return [params, jest.fn()];
|
||||
}
|
||||
|
||||
// ── Setup ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useParams.mockReturnValue({ id: EXP_ID, date: DATE_STR });
|
||||
useSearchParams.mockReturnValue(makeSearchParams(FIELD_ID));
|
||||
experimentsApi.get.mockResolvedValue(EXPERIMENT);
|
||||
experimentsApi.getDayStatuses.mockResolvedValue([]);
|
||||
experimentsApi.getCalendar.mockResolvedValue({ field: FIELD_ID, days: {} });
|
||||
dailyStatusesApi.update.mockResolvedValue({});
|
||||
});
|
||||
|
||||
// ── Loading & error states ────────────────────────────────────────────────────
|
||||
|
||||
describe('loading and error states', () => {
|
||||
it('shows a loading indicator while data is being fetched', () => {
|
||||
experimentsApi.get.mockReturnValue(new Promise(() => {})); // never resolves
|
||||
render(<ExperimentDayView />);
|
||||
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an error message when the API call fails', async () => {
|
||||
experimentsApi.get.mockRejectedValue({ message: 'Network error' });
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() => expect(screen.getByText(/network error/i)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows empty state when no data for the day', async () => {
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/no data recorded for this day/i)).toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Breadcrumb ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('breadcrumb', () => {
|
||||
it('renders dashboard, experiment title, and formatted date', async () => {
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() => screen.getByText('Reach Task Study'));
|
||||
expect(screen.getByRole('link', { name: 'Dashboard' })).toHaveAttribute('href', '/');
|
||||
expect(screen.getByRole('link', { name: 'Reach Task Study' })).toHaveAttribute('href', `/experiments/${EXP_ID}`);
|
||||
expect(screen.getAllByText('April 23, 2026').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Row filtering by selected field ──────────────────────────────────────────
|
||||
|
||||
describe('row filtering', () => {
|
||||
it('shows animals that have a non-empty value for the selected field', async () => {
|
||||
experimentsApi.getDayStatuses.mockResolvedValue([
|
||||
makeStatus('a1000000-0000-0000-0000-000000000001', { custom_fields: { [FIELD_ID]: '325' } }),
|
||||
makeStatus('a2000000-0000-0000-0000-000000000002', { custom_fields: { [FIELD_ID]: '310' } }),
|
||||
]);
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() => screen.getByText('Rat A'));
|
||||
expect(screen.getByText('Rat A')).toBeInTheDocument();
|
||||
expect(screen.getByText('Rat B')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('excludes animals whose selected field value is empty', async () => {
|
||||
experimentsApi.getDayStatuses.mockResolvedValue([
|
||||
makeStatus('a1000000-0000-0000-0000-000000000001', { custom_fields: { [FIELD_ID]: '325' } }),
|
||||
makeStatus('a2000000-0000-0000-0000-000000000002', { custom_fields: {} }), // no weight
|
||||
]);
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() => screen.getByText('Rat A'));
|
||||
expect(screen.queryByText('Rat B')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('excludes animals whose selected field value is null', async () => {
|
||||
experimentsApi.getDayStatuses.mockResolvedValue([
|
||||
makeStatus('a1000000-0000-0000-0000-000000000001', { custom_fields: { [FIELD_ID]: null } }),
|
||||
]);
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/no data recorded/i)).toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
it('excludes animals with no status record for that day', async () => {
|
||||
// Only Rat A has a status; Rat B and C have no record returned
|
||||
experimentsApi.getDayStatuses.mockResolvedValue([
|
||||
makeStatus('a1000000-0000-0000-0000-000000000001', { custom_fields: { [FIELD_ID]: '325' } }),
|
||||
]);
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() => screen.getByText('Rat A'));
|
||||
expect(screen.queryByText('Rat B')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Rat C')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows all animals with a status record when no field filter is set', async () => {
|
||||
useSearchParams.mockReturnValue(makeSearchParams(null));
|
||||
experimentsApi.getDayStatuses.mockResolvedValue([
|
||||
makeStatus('a1000000-0000-0000-0000-000000000001'),
|
||||
makeStatus('a2000000-0000-0000-0000-000000000002'),
|
||||
]);
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() => screen.getByText('Rat A'));
|
||||
expect(screen.getByText('Rat A')).toBeInTheDocument();
|
||||
expect(screen.getByText('Rat B')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters by builtin field (vitals)', async () => {
|
||||
useSearchParams.mockReturnValue(makeSearchParams('vitals'));
|
||||
experimentsApi.getDayStatuses.mockResolvedValue([
|
||||
makeStatus('a1000000-0000-0000-0000-000000000001', { vitals: 'HR 72' }),
|
||||
makeStatus('a2000000-0000-0000-0000-000000000002', { vitals: null }),
|
||||
]);
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() => screen.getByText('Rat A'));
|
||||
expect(screen.queryByText('Rat B')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Table structure ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('table structure', () => {
|
||||
beforeEach(() => {
|
||||
experimentsApi.getDayStatuses.mockResolvedValue([
|
||||
makeStatus('a1000000-0000-0000-0000-000000000001', {
|
||||
vitals: 'HR 72',
|
||||
custom_fields: { [FIELD_ID]: '325' },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders a column header for each active template field', async () => {
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() => screen.getByText('Rat A'));
|
||||
// Active fields: Vitals, Weight. Notes is inactive.
|
||||
expect(screen.getByText('Vitals')).toBeInTheDocument();
|
||||
expect(screen.getByText('Weight')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Notes')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders field values in the correct cells', async () => {
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() => screen.getByText('325'));
|
||||
expect(screen.getByText('HR 72')).toBeInTheDocument();
|
||||
expect(screen.getByText('325')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders dash for empty field values', async () => {
|
||||
experimentsApi.getDayStatuses.mockResolvedValue([
|
||||
makeStatus('a1000000-0000-0000-0000-000000000001', {
|
||||
vitals: null,
|
||||
custom_fields: { [FIELD_ID]: '325' },
|
||||
}),
|
||||
]);
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() => screen.getByText('Rat A'));
|
||||
expect(screen.getByText('—')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the subject name with animal_id_string alongside', async () => {
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() => screen.getByText('Rat A'));
|
||||
expect(screen.getByText('R001')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders an Edit button for each row', async () => {
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() => screen.getByText('Rat A'));
|
||||
expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Subject name navigation ───────────────────────────────────────────────────
|
||||
|
||||
describe('subject name click', () => {
|
||||
it('navigates to /daily-statuses/:id when subject name is clicked', async () => {
|
||||
const statusId = 'status-a1000000-0000-0000-0000-000000000001';
|
||||
experimentsApi.getDayStatuses.mockResolvedValue([
|
||||
makeStatus('a1000000-0000-0000-0000-000000000001', { custom_fields: { [FIELD_ID]: '325' } }),
|
||||
]);
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() => screen.getByText('Rat A'));
|
||||
fireEvent.click(screen.getByText('Rat A'));
|
||||
expect(mockNavigate).toHaveBeenCalledWith(`/daily-statuses/${statusId}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Edit modal ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('edit modal', () => {
|
||||
beforeEach(() => {
|
||||
experimentsApi.getDayStatuses.mockResolvedValue([
|
||||
makeStatus('a1000000-0000-0000-0000-000000000001', { custom_fields: { [FIELD_ID]: '325' } }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('opens the edit modal when Edit button is clicked', async () => {
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() => screen.getByText('Rat A'));
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('daily-status-form')).toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
it('closes the edit modal when Cancel is clicked', async () => {
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() => screen.getByText('Rat A'));
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
|
||||
await waitFor(() => screen.getByTestId('daily-status-form'));
|
||||
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('daily-status-form')).not.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
it('closes the edit modal and updates the row after successful submit', async () => {
|
||||
const originalStatus = makeStatus('a1000000-0000-0000-0000-000000000001', {
|
||||
custom_fields: { [FIELD_ID]: '325' },
|
||||
});
|
||||
experimentsApi.getDayStatuses.mockResolvedValue([originalStatus]);
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() => screen.getByText('Rat A'));
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
|
||||
await waitFor(() => screen.getByTestId('daily-status-form'));
|
||||
// Submit with updated status
|
||||
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('daily-status-form')).not.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Session metrics bar chart ─────────────────────────────────────────────────
|
||||
//
|
||||
// Bar chart reads from status.analysis_summary — pre-computed trial-level
|
||||
// session metrics: { total, counts: { Success, Failure }, success_rate }
|
||||
// X-axis: animals visible in the table (have non-empty selected field value).
|
||||
// Hidden when no visible animal has analysis_summary.total > 0.
|
||||
|
||||
describe('session metrics bar chart', () => {
|
||||
it('renders bar chart when at least one visible animal has analysis_summary', async () => {
|
||||
const summary = { total: 18, counts: { Success: 5, Failure: 13 }, success_rate: 0.28 };
|
||||
experimentsApi.getDayStatuses.mockResolvedValue([
|
||||
makeStatus('a1000000-0000-0000-0000-000000000001', {
|
||||
custom_fields: { [FIELD_ID]: '325' },
|
||||
analysis_summary: summary,
|
||||
}),
|
||||
]);
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() => screen.getByText('Rat A'));
|
||||
expect(screen.getByTestId('bar-chart')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides bar chart when no visible animal has analysis_summary', async () => {
|
||||
experimentsApi.getDayStatuses.mockResolvedValue([
|
||||
makeStatus('a1000000-0000-0000-0000-000000000001', {
|
||||
custom_fields: { [FIELD_ID]: '325' },
|
||||
analysis_summary: null,
|
||||
}),
|
||||
]);
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() => screen.getByText('Rat A'));
|
||||
expect(screen.queryByTestId('bar-chart')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides bar chart when analysis_summary.total is 0', async () => {
|
||||
const emptySummary = { total: 0, counts: { Success: 0, Failure: 0 }, success_rate: null };
|
||||
experimentsApi.getDayStatuses.mockResolvedValue([
|
||||
makeStatus('a1000000-0000-0000-0000-000000000001', {
|
||||
custom_fields: { [FIELD_ID]: '325' },
|
||||
analysis_summary: emptySummary,
|
||||
}),
|
||||
]);
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() => screen.getByText('Rat A'));
|
||||
expect(screen.queryByTestId('bar-chart')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('excludes from chart animals that are filtered out by the selected field', async () => {
|
||||
// Rat A: has field value + analysis_summary → in table and chart
|
||||
// Rat B: NO field value, has analysis_summary → excluded from both
|
||||
const summary = { total: 10, counts: { Success: 7, Failure: 3 }, success_rate: 0.7 };
|
||||
experimentsApi.getDayStatuses.mockResolvedValue([
|
||||
makeStatus('a1000000-0000-0000-0000-000000000001', {
|
||||
custom_fields: { [FIELD_ID]: '325' },
|
||||
analysis_summary: summary,
|
||||
}),
|
||||
makeStatus('a2000000-0000-0000-0000-000000000002', {
|
||||
custom_fields: {},
|
||||
analysis_summary: summary,
|
||||
}),
|
||||
]);
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() => screen.getByText('Rat A'));
|
||||
// Chart still renders because Rat A has data
|
||||
expect(screen.getByTestId('bar-chart')).toBeInTheDocument();
|
||||
// Rat B not in table
|
||||
expect(screen.queryByText('Rat B')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows bar chart for multiple animals each with their own analysis_summary', async () => {
|
||||
experimentsApi.getDayStatuses.mockResolvedValue([
|
||||
makeStatus('a1000000-0000-0000-0000-000000000001', {
|
||||
custom_fields: { [FIELD_ID]: '325' },
|
||||
analysis_summary: { total: 18, counts: { Success: 5, Failure: 13 }, success_rate: 0.28 },
|
||||
}),
|
||||
makeStatus('a2000000-0000-0000-0000-000000000002', {
|
||||
custom_fields: { [FIELD_ID]: '310' },
|
||||
analysis_summary: { total: 10, counts: { Success: 7, Failure: 3 }, success_rate: 0.7 },
|
||||
}),
|
||||
]);
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() => screen.getByText('Rat A'));
|
||||
expect(screen.getByText('Rat B')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('bar-chart')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── API call parameters ───────────────────────────────────────────────────────
|
||||
|
||||
describe('API calls', () => {
|
||||
it('calls getDayStatuses with the experiment id and date from the URL', async () => {
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() =>
|
||||
expect(experimentsApi.getDayStatuses).toHaveBeenCalledWith(EXP_ID, DATE_STR)
|
||||
);
|
||||
});
|
||||
|
||||
it('calls experimentsApi.get with the experiment id', async () => {
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() =>
|
||||
expect(experimentsApi.get).toHaveBeenCalledWith(EXP_ID)
|
||||
);
|
||||
});
|
||||
|
||||
it('calls getCalendar with experiment id and selected field for day navigation', async () => {
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() =>
|
||||
expect(experimentsApi.getCalendar).toHaveBeenCalledWith(EXP_ID, FIELD_ID)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Inline cell editing ───────────────────────────────────────────────────────
|
||||
|
||||
describe('inline cell editing', () => {
|
||||
const STATUS_ID = 'status-a1000000-0000-0000-0000-000000000001';
|
||||
|
||||
beforeEach(() => {
|
||||
experimentsApi.getDayStatuses.mockResolvedValue([
|
||||
makeStatus('a1000000-0000-0000-0000-000000000001', {
|
||||
custom_fields: { [FIELD_ID]: '325' },
|
||||
vitals: 'HR 72',
|
||||
}),
|
||||
]);
|
||||
dailyStatusesApi.update.mockResolvedValue(
|
||||
makeStatus('a1000000-0000-0000-0000-000000000001', {
|
||||
custom_fields: { [FIELD_ID]: '330' },
|
||||
vitals: 'HR 72',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('shows an input when a cell value is clicked', async () => {
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() => screen.getByText('325'));
|
||||
fireEvent.click(screen.getByText('325'));
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an input when a dash (empty cell) is clicked', async () => {
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() => screen.getByText('Rat A'));
|
||||
// Vitals is shown as "HR 72" but weight is selected, click on a dash cell in vitals column
|
||||
// After the status above vitals = 'HR 72' (not empty), let's use a status without vitals
|
||||
experimentsApi.getDayStatuses.mockResolvedValue([
|
||||
makeStatus('a1000000-0000-0000-0000-000000000001', {
|
||||
custom_fields: { [FIELD_ID]: '325' },
|
||||
vitals: null,
|
||||
}),
|
||||
]);
|
||||
// Re-render
|
||||
const { unmount } = render(<ExperimentDayView />);
|
||||
await waitFor(() => screen.getAllByText('Rat A'));
|
||||
const dashCell = screen.getAllByText('—')[0];
|
||||
fireEvent.click(dashCell);
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('saves a custom field value on Enter key', async () => {
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() => screen.getByText('325'));
|
||||
fireEvent.click(screen.getByText('325'));
|
||||
const input = screen.getByRole('textbox');
|
||||
fireEvent.change(input, { target: { value: '330' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
await waitFor(() =>
|
||||
expect(dailyStatusesApi.update).toHaveBeenCalledWith(
|
||||
STATUS_ID,
|
||||
expect.objectContaining({ custom_fields: expect.objectContaining({ [FIELD_ID]: '330' }) })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('saves on blur', async () => {
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() => screen.getByText('325'));
|
||||
fireEvent.click(screen.getByText('325'));
|
||||
const input = screen.getByRole('textbox');
|
||||
fireEvent.change(input, { target: { value: '328' } });
|
||||
fireEvent.blur(input);
|
||||
await waitFor(() =>
|
||||
expect(dailyStatusesApi.update).toHaveBeenCalledWith(
|
||||
STATUS_ID,
|
||||
expect.objectContaining({ custom_fields: expect.objectContaining({ [FIELD_ID]: '328' }) })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('cancels edit on Escape without saving', async () => {
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() => screen.getByText('325'));
|
||||
fireEvent.click(screen.getByText('325'));
|
||||
const input = screen.getByRole('textbox');
|
||||
fireEvent.change(input, { target: { value: '999' } });
|
||||
fireEvent.keyDown(input, { key: 'Escape' });
|
||||
expect(dailyStatusesApi.update).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(screen.queryByRole('textbox')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('skips the API call when the value is unchanged', async () => {
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() => screen.getByText('325'));
|
||||
fireEvent.click(screen.getByText('325'));
|
||||
const input = screen.getByRole('textbox');
|
||||
// value unchanged — blur without editing
|
||||
fireEvent.blur(input);
|
||||
expect(dailyStatusesApi.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('saves a builtin field (vitals) with the field key as payload key', async () => {
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() => screen.getByText('HR 72'));
|
||||
fireEvent.click(screen.getByText('HR 72'));
|
||||
const input = screen.getByRole('textbox');
|
||||
fireEvent.change(input, { target: { value: 'HR 80' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
await waitFor(() =>
|
||||
expect(dailyStatusesApi.update).toHaveBeenCalledWith(
|
||||
STATUS_ID,
|
||||
expect.objectContaining({ vitals: 'HR 80' })
|
||||
)
|
||||
);
|
||||
// custom_fields must NOT be in the payload for a builtin field
|
||||
const payload = dailyStatusesApi.update.mock.calls[0][1];
|
||||
expect(payload).not.toHaveProperty('custom_fields');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Prev / next day navigation ────────────────────────────────────────────────
|
||||
|
||||
describe('prev/next day navigation', () => {
|
||||
it('shows no navigation buttons when there are no other days', async () => {
|
||||
experimentsApi.getCalendar.mockResolvedValue({
|
||||
field: FIELD_ID,
|
||||
days: { [DATE_STR]: 2 },
|
||||
});
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() => expect(experimentsApi.getCalendar).toHaveBeenCalled());
|
||||
expect(screen.queryByText(/← April/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/April.*→/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a previous day button when an earlier day has data', async () => {
|
||||
experimentsApi.getCalendar.mockResolvedValue({
|
||||
field: FIELD_ID,
|
||||
days: { '2026-04-22': 1, [DATE_STR]: 2 },
|
||||
});
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() => screen.getByText(/April 22, 2026/));
|
||||
expect(screen.getByText(/← April 22, 2026/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a next day button when a later day has data', async () => {
|
||||
experimentsApi.getCalendar.mockResolvedValue({
|
||||
field: FIELD_ID,
|
||||
days: { [DATE_STR]: 2, '2026-04-24': 1 },
|
||||
});
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() => screen.getByText(/April 24, 2026/));
|
||||
expect(screen.getByText(/April 24, 2026 →/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows both prev and next buttons when surrounded by days with data', async () => {
|
||||
experimentsApi.getCalendar.mockResolvedValue({
|
||||
field: FIELD_ID,
|
||||
days: { '2026-04-21': 1, [DATE_STR]: 2, '2026-04-25': 3 },
|
||||
});
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() => screen.getByText(/April 21, 2026/));
|
||||
expect(screen.getByText(/← April 21, 2026/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/April 25, 2026 →/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates to the previous day with the field param preserved', async () => {
|
||||
experimentsApi.getCalendar.mockResolvedValue({
|
||||
field: FIELD_ID,
|
||||
days: { '2026-04-22': 1, [DATE_STR]: 2 },
|
||||
});
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() => screen.getByText(/← April 22, 2026/));
|
||||
fireEvent.click(screen.getByText(/← April 22, 2026/));
|
||||
expect(mockNavigate).toHaveBeenCalledWith(
|
||||
`/experiments/${EXP_ID}/day/2026-04-22?field=${FIELD_ID}`
|
||||
);
|
||||
});
|
||||
|
||||
it('navigates to the next day with the field param preserved', async () => {
|
||||
experimentsApi.getCalendar.mockResolvedValue({
|
||||
field: FIELD_ID,
|
||||
days: { [DATE_STR]: 2, '2026-04-24': 1 },
|
||||
});
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() => screen.getByText(/April 24, 2026 →/));
|
||||
fireEvent.click(screen.getByText(/April 24, 2026 →/));
|
||||
expect(mockNavigate).toHaveBeenCalledWith(
|
||||
`/experiments/${EXP_ID}/day/2026-04-24?field=${FIELD_ID}`
|
||||
);
|
||||
});
|
||||
|
||||
it('only shows adjacent days, not all days (prev is immediately before in sorted list)', async () => {
|
||||
experimentsApi.getCalendar.mockResolvedValue({
|
||||
field: FIELD_ID,
|
||||
days: { '2026-04-10': 1, '2026-04-20': 2, [DATE_STR]: 3, '2026-04-30': 1 },
|
||||
});
|
||||
render(<ExperimentDayView />);
|
||||
await waitFor(() => screen.getByText(/April 20, 2026/));
|
||||
// Prev should be April 20 (immediately before), not April 10
|
||||
expect(screen.getByText(/← April 20, 2026/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/April 10/)).not.toBeInTheDocument();
|
||||
// Next should be April 30 (immediately after)
|
||||
expect(screen.getByText(/April 30, 2026 →/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import ExperimentForm from '../src/components/ExperimentForm';
|
||||
import * as client from '../src/api/client';
|
||||
|
||||
jest.mock('../src/api/client', () => ({
|
||||
experimentsApi: {
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const onSuccess = jest.fn();
|
||||
const onCancel = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('ExperimentForm', () => {
|
||||
it('renders title input and submit button', () => {
|
||||
render(<ExperimentForm onSuccess={onSuccess} onCancel={onCancel} />);
|
||||
expect(screen.getByLabelText(/experiment title/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /create experiment/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows validation error when submitting empty title', async () => {
|
||||
render(<ExperimentForm onSuccess={onSuccess} onCancel={onCancel} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /create experiment/i }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(/title is required/i);
|
||||
expect(client.experimentsApi.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows error when title exceeds 255 chars', async () => {
|
||||
render(<ExperimentForm onSuccess={onSuccess} onCancel={onCancel} />);
|
||||
await userEvent.type(screen.getByLabelText(/experiment title/i), 'x'.repeat(256));
|
||||
fireEvent.click(screen.getByRole('button', { name: /create experiment/i }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(/255/i);
|
||||
expect(client.experimentsApi.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls create API with trimmed title and calls onSuccess', async () => {
|
||||
const experiment = { id: 'abc-123', title: 'New Study' };
|
||||
client.experimentsApi.create.mockResolvedValue(experiment);
|
||||
|
||||
render(<ExperimentForm onSuccess={onSuccess} onCancel={onCancel} />);
|
||||
await userEvent.type(screen.getByLabelText(/experiment title/i), 'New Study');
|
||||
fireEvent.click(screen.getByRole('button', { name: /create experiment/i }));
|
||||
|
||||
await waitFor(() => expect(client.experimentsApi.create).toHaveBeenCalledWith({ title: 'New Study' }));
|
||||
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(experiment));
|
||||
});
|
||||
|
||||
it('shows "Save Changes" button for existing experiment', () => {
|
||||
render(
|
||||
<ExperimentForm
|
||||
existing={{ id: 'abc', title: 'Old Title' }}
|
||||
onSuccess={onSuccess}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole('button', { name: /save changes/i })).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Old Title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls update API for existing experiment', async () => {
|
||||
const updated = { id: 'abc', title: 'Updated Title' };
|
||||
client.experimentsApi.update.mockResolvedValue(updated);
|
||||
|
||||
render(
|
||||
<ExperimentForm
|
||||
existing={{ id: 'abc', title: 'Old Title' }}
|
||||
onSuccess={onSuccess}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByDisplayValue('Old Title');
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'Updated Title');
|
||||
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(client.experimentsApi.update).toHaveBeenCalledWith('abc', { title: 'Updated Title' })
|
||||
);
|
||||
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(updated));
|
||||
});
|
||||
|
||||
it('calls onCancel when Cancel is clicked', () => {
|
||||
render(<ExperimentForm onSuccess={onSuccess} onCancel={onCancel} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows API error when create fails', async () => {
|
||||
client.experimentsApi.create.mockRejectedValue({ message: 'Server error', details: null });
|
||||
|
||||
render(<ExperimentForm onSuccess={onSuccess} onCancel={onCancel} />);
|
||||
await userEvent.type(screen.getByLabelText(/experiment title/i), 'Test');
|
||||
fireEvent.click(screen.getByRole('button', { name: /create experiment/i }));
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(/server error/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import ExportDataModal from '../src/components/ExportDataModal';
|
||||
import * as client from '../src/api/client';
|
||||
|
||||
jest.mock('../src/api/client', () => ({
|
||||
experimentsApi: { getDailyStatuses: jest.fn() },
|
||||
}));
|
||||
|
||||
const animals = [
|
||||
{ id: 'a1', animal_name: 'Alpha', animal_id_string: 'R-001', subject_info: { grp: 'Control' } },
|
||||
{ id: 'a2', animal_name: 'Beta', animal_id_string: 'R-002', subject_info: { grp: 'Drug' } },
|
||||
];
|
||||
const statuses = [
|
||||
{ animal_id: 'a1', date: '2026-07-01T00:00:00Z', analysis_summary: { total: 5, success_rate: 0.5 } },
|
||||
{ animal_id: 'a1', date: '2026-07-02T00:00:00Z', analysis_summary: { total: 7, success_rate: 0.7 } },
|
||||
{ animal_id: 'a2', date: '2026-07-01T00:00:00Z', analysis_summary: { total: 9, success_rate: 0.9 } },
|
||||
];
|
||||
const subjectTemplate = [{ fieldId: 'grp', label: 'Group', active: true }];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
client.experimentsApi.getDailyStatuses.mockResolvedValue(statuses);
|
||||
});
|
||||
|
||||
// Intercept the Blob handed to URL.createObjectURL so tests can read the real CSV.
|
||||
// Also stub anchor.click() — jsdom treats a click on <a href="blob:..."> as an
|
||||
// unimplemented navigation that throws async and can flake a later test.
|
||||
function mockDownload() {
|
||||
let blob = null;
|
||||
const origCreate = global.URL.createObjectURL;
|
||||
const origRevoke = global.URL.revokeObjectURL;
|
||||
const origClick = window.HTMLAnchorElement.prototype.click;
|
||||
global.URL.createObjectURL = jest.fn((b) => { blob = b; return 'blob:mock'; });
|
||||
global.URL.revokeObjectURL = jest.fn();
|
||||
window.HTMLAnchorElement.prototype.click = jest.fn();
|
||||
return {
|
||||
getBlob: () => blob,
|
||||
restore: async () => {
|
||||
await new Promise((r) => setTimeout(r, 0)); // flush handleExport's setTimeout revoke
|
||||
global.URL.createObjectURL = origCreate;
|
||||
global.URL.revokeObjectURL = origRevoke;
|
||||
window.HTMLAnchorElement.prototype.click = origClick;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readBlobText(blob) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fr = new FileReader();
|
||||
fr.onload = () => resolve(fr.result);
|
||||
fr.onerror = reject;
|
||||
fr.readAsText(blob);
|
||||
});
|
||||
}
|
||||
|
||||
function renderModal(props = {}) {
|
||||
return render(
|
||||
<ExportDataModal
|
||||
experimentId="exp-1"
|
||||
experimentTitle="My Study"
|
||||
dailyTemplate={[]}
|
||||
animals={animals}
|
||||
subjectTemplate={subjectTemplate}
|
||||
groupField="__none__"
|
||||
onClose={jest.fn()}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ExportDataModal', () => {
|
||||
it('renders X / Data / Group by pickers; X defaults to Date', async () => {
|
||||
renderModal();
|
||||
expect(await screen.findByLabelText('X axis (rows)')).toHaveValue('__date__');
|
||||
expect(screen.getByLabelText('Data (cells)')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Group by')).toHaveValue('__none__');
|
||||
await waitFor(() => expect(screen.getByText(/2 rows × 2 subjects/i)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('default export (X=Date, Data=Total attempts) has subject columns, date rows, blank where missing', async () => {
|
||||
const dl = mockDownload();
|
||||
renderModal();
|
||||
await screen.findByLabelText('X axis (rows)');
|
||||
await waitFor(() => expect(screen.getByText(/2 rows/i)).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByText('Export CSV'));
|
||||
const text = await readBlobText(dl.getBlob());
|
||||
expect(text).toBe('Data,Total attempts\n\nDate,Alpha,Beta\n2026-07-01,5,9\n2026-07-02,7,');
|
||||
await dl.restore();
|
||||
});
|
||||
|
||||
it('switching X to # Days Reach makes rows the day ordinals', async () => {
|
||||
const dl = mockDownload();
|
||||
renderModal();
|
||||
fireEvent.change(await screen.findByLabelText('X axis (rows)'), { target: { value: '__days__' } });
|
||||
await waitFor(() => expect(screen.getByText(/2 rows/i)).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByText('Export CSV'));
|
||||
const text = await readBlobText(dl.getBlob());
|
||||
expect(text).toBe('Data,Total attempts\n\n# Days Reach (ordinal),Alpha,Beta\n1,5,9\n2,7,');
|
||||
await dl.restore();
|
||||
});
|
||||
|
||||
it('a valid group field adds a Group row and clusters subjects', async () => {
|
||||
const dl = mockDownload();
|
||||
renderModal({ groupField: 'grp' });
|
||||
await screen.findByLabelText('X axis (rows)');
|
||||
await waitFor(() => expect(screen.getByText(/2 rows/i)).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByText('Export CSV'));
|
||||
const text = await readBlobText(dl.getBlob());
|
||||
expect(text).toMatch(/^Group,Control,Drug$/m);
|
||||
await dl.restore();
|
||||
});
|
||||
|
||||
it('a stale/unset group field coerces to None (no Group row in the export)', async () => {
|
||||
const dl = mockDownload();
|
||||
renderModal({ groupField: 'ghost-field' });
|
||||
await screen.findByLabelText('X axis (rows)');
|
||||
await waitFor(() => expect(screen.getByText(/2 rows/i)).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByText('Export CSV'));
|
||||
const text = await readBlobText(dl.getBlob());
|
||||
expect(text).not.toMatch(/^Group,/m);
|
||||
await dl.restore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,375 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
||||
import FileDropZone from '../src/components/FileDropZone';
|
||||
import { sessionFilesApi } from '../src/api/client';
|
||||
|
||||
jest.mock('../src/api/client', () => ({
|
||||
sessionFilesApi: {
|
||||
list: jest.fn(),
|
||||
create: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Fixtures ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const DATE = '2026-04-23';
|
||||
|
||||
const ANIMALS = [
|
||||
{ id: 'a1', animal_name: 'Rat 2852', animal_id_string: '2852' },
|
||||
{ id: 'a2', animal_name: 'Rat 3076', animal_id_string: '3076' },
|
||||
{ id: 'a3', animal_name: 'Rat 3077', animal_id_string: '3077' },
|
||||
];
|
||||
|
||||
const STATUS_A1 = { id: 's-a1', animal_id: 'a1', date: `${DATE}T00:00:00.000Z` };
|
||||
const STATUS_A2 = { id: 's-a2', animal_id: 'a2', date: `${DATE}T00:00:00.000Z` };
|
||||
const STATUS_A3 = { id: 's-a3', animal_id: 'a3', date: `${DATE}T00:00:00.000Z` };
|
||||
|
||||
const ROWS = [
|
||||
{ animal: ANIMALS[0], status: STATUS_A1 },
|
||||
{ animal: ANIMALS[1], status: STATUS_A2 },
|
||||
{ animal: ANIMALS[2], status: STATUS_A3 },
|
||||
];
|
||||
|
||||
function makeFile(name, size = 1024, type = '') {
|
||||
return new File(['x'], name, { type, lastModified: Date.now() });
|
||||
}
|
||||
|
||||
function makeSavedFile(id, statusId, filename, fileSize = '10240') {
|
||||
return { id, daily_status_id: statusId, filename, file_size: fileSize, file_type: 'CSV', last_modified: null, created_at: new Date().toISOString() };
|
||||
}
|
||||
|
||||
function dropFiles(files) {
|
||||
const zone = screen.getByTestId('drop-zone');
|
||||
fireEvent.dragEnter(zone, { dataTransfer: { files } });
|
||||
fireEvent.dragOver(zone, { dataTransfer: { files } });
|
||||
fireEvent.drop(zone, { dataTransfer: { files } });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
sessionFilesApi.list.mockResolvedValue([]);
|
||||
sessionFilesApi.create.mockResolvedValue([]);
|
||||
sessionFilesApi.delete.mockResolvedValue();
|
||||
});
|
||||
|
||||
// ── Rendering ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders the drop zone', () => {
|
||||
render(<FileDropZone rows={ROWS} date={DATE} />);
|
||||
expect(screen.getByTestId('drop-zone')).toBeInTheDocument();
|
||||
expect(screen.getByText(/drag.*drop session files/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows naming convention hint using first animal id', () => {
|
||||
render(<FileDropZone rows={ROWS} date={DATE} />);
|
||||
expect(screen.getByText(/2026-04-23_2852/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loads saved files on mount for each status', async () => {
|
||||
sessionFilesApi.list.mockResolvedValue([]);
|
||||
render(<FileDropZone rows={ROWS} date={DATE} />);
|
||||
await waitFor(() => {
|
||||
expect(sessionFilesApi.list).toHaveBeenCalledWith('s-a1');
|
||||
expect(sessionFilesApi.list).toHaveBeenCalledWith('s-a2');
|
||||
expect(sessionFilesApi.list).toHaveBeenCalledWith('s-a3');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows saved files loaded from the API', async () => {
|
||||
sessionFilesApi.list.mockImplementation((id) =>
|
||||
id === 's-a1'
|
||||
? Promise.resolve([makeSavedFile('f1', 's-a1', `${DATE}_2852_trials.csv`)])
|
||||
: Promise.resolve([])
|
||||
);
|
||||
render(<FileDropZone rows={ROWS} date={DATE} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(`${DATE}_2852_trials.csv`)).toBeInTheDocument()
|
||||
);
|
||||
expect(screen.getByTestId('saved-files')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Drag state ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('drag interaction', () => {
|
||||
it('highlights drop zone on dragenter', () => {
|
||||
render(<FileDropZone rows={ROWS} date={DATE} />);
|
||||
const zone = screen.getByTestId('drop-zone');
|
||||
fireEvent.dragEnter(zone, { dataTransfer: { files: [] } });
|
||||
expect(zone.className).toMatch(/border-blue-400/);
|
||||
expect(screen.getByText(/release to register/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('removes highlight on dragleave', () => {
|
||||
render(<FileDropZone rows={ROWS} date={DATE} />);
|
||||
const zone = screen.getByTestId('drop-zone');
|
||||
fireEvent.dragEnter(zone, { dataTransfer: { files: [] } });
|
||||
fireEvent.dragLeave(zone, { dataTransfer: { files: [] } });
|
||||
expect(zone.className).not.toMatch(/border-blue-400/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── File matching ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('filename-to-subject matching', () => {
|
||||
it('matches files containing the animal_id_string to the correct subject', async () => {
|
||||
render(<FileDropZone rows={ROWS} date={DATE} />);
|
||||
dropFiles([
|
||||
makeFile(`${DATE}_2852_ephys_raw.bin`, 10_000_000_000),
|
||||
makeFile(`${DATE}_3076_trials.csv`, 10_000_000),
|
||||
]);
|
||||
await waitFor(() => screen.getByTestId('drop-preview'));
|
||||
expect(screen.getByText(/Rat 2852/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Rat 3076/)).toBeInTheDocument();
|
||||
expect(screen.getByText(`${DATE}_2852_ephys_raw.bin`)).toBeInTheDocument();
|
||||
expect(screen.getByText(`${DATE}_3076_trials.csv`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows unmatched section for files with no subject ID in name', async () => {
|
||||
render(<FileDropZone rows={ROWS} date={DATE} />);
|
||||
dropFiles([makeFile('unknown_file.csv', 1024)]);
|
||||
await waitFor(() => screen.getByTestId('drop-preview'));
|
||||
expect(screen.getByText(/unmatched/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('unknown_file.csv')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not match a partial numeric overlap (2852 should not match 28520)', async () => {
|
||||
render(<FileDropZone rows={ROWS} date={DATE} />);
|
||||
dropFiles([makeFile('session_28520_data.csv', 1024)]);
|
||||
await waitFor(() => screen.getByTestId('drop-preview'));
|
||||
expect(screen.getByText(/unmatched/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── animal_name matching ───────────────────────────────────────────────────
|
||||
|
||||
it('matches by animal_name when the name appears literally in the filename', async () => {
|
||||
const animal = { id: 'b1', animal_name: 'Homer', animal_id_string: null };
|
||||
const row = { animal, status: { id: 's-b1', animal_id: 'b1', date: `${DATE}T00:00:00.000Z` } };
|
||||
render(<FileDropZone rows={[row]} date={DATE} />);
|
||||
dropFiles([makeFile(`${DATE}_Homer_session.csv`)]);
|
||||
await waitFor(() => screen.getByTestId('drop-preview'));
|
||||
expect(screen.queryByText(/unmatched/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(`${DATE}_Homer_session.csv`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('matches multi-word animal_name with underscores as separator in filename', async () => {
|
||||
const animal = { id: 'b1', animal_name: 'Rat A', animal_id_string: null };
|
||||
const row = { animal, status: { id: 's-b1', animal_id: 'b1', date: `${DATE}T00:00:00.000Z` } };
|
||||
render(<FileDropZone rows={[row]} date={DATE} />);
|
||||
dropFiles([makeFile(`${DATE}_Rat_A_trials.csv`)]);
|
||||
await waitFor(() => screen.getByTestId('drop-preview'));
|
||||
expect(screen.queryByText(/unmatched/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('matches multi-word animal_name with dashes as separator in filename', async () => {
|
||||
const animal = { id: 'b1', animal_name: 'Rat A', animal_id_string: null };
|
||||
const row = { animal, status: { id: 's-b1', animal_id: 'b1', date: `${DATE}T00:00:00.000Z` } };
|
||||
render(<FileDropZone rows={[row]} date={DATE} />);
|
||||
dropFiles([makeFile(`${DATE}-Rat-A-session.bin`)]);
|
||||
await waitFor(() => screen.getByTestId('drop-preview'));
|
||||
expect(screen.queryByText(/unmatched/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('matches by animal_name even when animal_id_string is also set (OR logic)', async () => {
|
||||
const animal = { id: 'b1', animal_name: 'Rat A', animal_id_string: '2852' };
|
||||
const row = { animal, status: { id: 's-b1', animal_id: 'b1', date: `${DATE}T00:00:00.000Z` } };
|
||||
render(<FileDropZone rows={[row]} date={DATE} />);
|
||||
// File uses name, not ID
|
||||
dropFiles([makeFile(`${DATE}_Rat_A_ephys.bin`)]);
|
||||
await waitFor(() => screen.getByTestId('drop-preview'));
|
||||
expect(screen.queryByText(/unmatched/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not match a partial name overlap (Rat should not match Rationale)', async () => {
|
||||
const animal = { id: 'b1', animal_name: 'Rat', animal_id_string: null };
|
||||
const row = { animal, status: { id: 's-b1', animal_id: 'b1', date: `${DATE}T00:00:00.000Z` } };
|
||||
render(<FileDropZone rows={[row]} date={DATE} />);
|
||||
dropFiles([makeFile('Rationale_study_data.csv')]);
|
||||
await waitFor(() => screen.getByTestId('drop-preview'));
|
||||
expect(screen.getByText(/unmatched/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows warning for subjects with no matched files', async () => {
|
||||
render(<FileDropZone rows={ROWS} date={DATE} />);
|
||||
// Only drop file for 2852 — 3076 and 3077 get no match
|
||||
dropFiles([makeFile(`${DATE}_2852_trials.csv`, 1024)]);
|
||||
await waitFor(() => screen.getByTestId('drop-preview'));
|
||||
expect(screen.getByText(/no files matched/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('3076')).toBeInTheDocument();
|
||||
expect(screen.getByText('3077')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('matches all four files per subject correctly', async () => {
|
||||
render(<FileDropZone rows={[{ animal: ANIMALS[0], status: STATUS_A1 }]} date={DATE} />);
|
||||
dropFiles([
|
||||
makeFile(`${DATE}_2852_ephys_raw.bin`, 10_000_000_000),
|
||||
makeFile(`${DATE}_2852_kilosort.h5`, 10_000_000_000),
|
||||
makeFile(`${DATE}_2852_trials.csv`, 10_000_000),
|
||||
makeFile(`${DATE}_2852_behavior_log.csv`, 10_000_000),
|
||||
]);
|
||||
await waitFor(() => screen.getByTestId('drop-preview'));
|
||||
// Save button label is the authoritative "all 4 matched" signal
|
||||
expect(screen.getByTestId('save-btn')).toHaveTextContent('Register 4 files');
|
||||
});
|
||||
|
||||
it('handles multiple subjects with 4 files each simultaneously', async () => {
|
||||
render(<FileDropZone rows={ROWS} date={DATE} />);
|
||||
dropFiles([
|
||||
makeFile(`${DATE}_2852_ephys_raw.bin`, 10_000_000_000),
|
||||
makeFile(`${DATE}_2852_kilosort.h5`, 10_000_000_000),
|
||||
makeFile(`${DATE}_2852_trials.csv`, 10_000_000),
|
||||
makeFile(`${DATE}_2852_behavior_log.csv`, 10_000_000),
|
||||
makeFile(`${DATE}_3076_ephys_raw.bin`, 10_000_000_000),
|
||||
makeFile(`${DATE}_3076_kilosort.h5`, 10_000_000_000),
|
||||
makeFile(`${DATE}_3076_trials.csv`, 10_000_000),
|
||||
makeFile(`${DATE}_3076_behavior_log.csv`, 10_000_000),
|
||||
]);
|
||||
await waitFor(() => screen.getByTestId('drop-preview'));
|
||||
const saveBtn = screen.getByTestId('save-btn');
|
||||
expect(saveBtn.textContent).toMatch(/8 files/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Remove before saving ───────────────────────────────────────────────────────
|
||||
|
||||
describe('remove files before saving', () => {
|
||||
it('removes a matched file when ✕ is clicked', async () => {
|
||||
render(<FileDropZone rows={ROWS} date={DATE} />);
|
||||
dropFiles([makeFile(`${DATE}_2852_trials.csv`, 1024)]);
|
||||
await waitFor(() => screen.getByText(`${DATE}_2852_trials.csv`));
|
||||
fireEvent.click(screen.getAllByText('✕')[0]);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText(`${DATE}_2852_trials.csv`)).not.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Saving ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('saving metadata', () => {
|
||||
it('calls sessionFilesApi.create with correct payload including matching metadata', async () => {
|
||||
const file = makeFile(`${DATE}_2852_trials.csv`);
|
||||
const savedFile = makeSavedFile('f1', 's-a1', file.name, String(file.size));
|
||||
sessionFilesApi.create.mockResolvedValue([savedFile]);
|
||||
|
||||
render(<FileDropZone rows={ROWS} date={DATE} />);
|
||||
dropFiles([file]);
|
||||
await waitFor(() => screen.getByTestId('save-btn'));
|
||||
fireEvent.click(screen.getByTestId('save-btn'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(sessionFilesApi.create).toHaveBeenCalledWith(
|
||||
's-a1',
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
filename: `${DATE}_2852_trials.csv`,
|
||||
file_size: file.size,
|
||||
file_type: 'CSV',
|
||||
matched_identifier: '2852',
|
||||
animal_id_string_snap: '2852',
|
||||
animal_name_snap: 'Rat 2852',
|
||||
}),
|
||||
])
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('sets matched_identifier to animal_name when file matched by name', async () => {
|
||||
const animal = { id: 'b1', animal_name: 'Rat A', animal_id_string: null };
|
||||
const row = { animal, status: { id: 's-b1', animal_id: 'b1', date: `${DATE}T00:00:00.000Z` } };
|
||||
sessionFilesApi.create.mockResolvedValue([]);
|
||||
render(<FileDropZone rows={[row]} date={DATE} />);
|
||||
dropFiles([makeFile(`${DATE}_Rat_A_trials.csv`)]);
|
||||
await waitFor(() => screen.getByTestId('save-btn'));
|
||||
fireEvent.click(screen.getByTestId('save-btn'));
|
||||
await waitFor(() =>
|
||||
expect(sessionFilesApi.create).toHaveBeenCalledWith(
|
||||
's-b1',
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
matched_identifier: 'Rat A',
|
||||
animal_id_string_snap: null,
|
||||
animal_name_snap: 'Rat A',
|
||||
}),
|
||||
])
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('does not call create for subjects with no matched files', async () => {
|
||||
sessionFilesApi.create.mockResolvedValue([]);
|
||||
render(<FileDropZone rows={ROWS} date={DATE} />);
|
||||
// Only 2852 gets a file
|
||||
dropFiles([makeFile(`${DATE}_2852_trials.csv`, 1024)]);
|
||||
await waitFor(() => screen.getByTestId('save-btn'));
|
||||
fireEvent.click(screen.getByTestId('save-btn'));
|
||||
|
||||
await waitFor(() => expect(sessionFilesApi.create).toHaveBeenCalledTimes(1));
|
||||
expect(sessionFilesApi.create).toHaveBeenCalledWith('s-a1', expect.any(Array));
|
||||
});
|
||||
|
||||
it('stores correct file_type classification from extension', async () => {
|
||||
sessionFilesApi.create.mockResolvedValue([]);
|
||||
render(<FileDropZone rows={[{ animal: ANIMALS[0], status: STATUS_A1 }]} date={DATE} />);
|
||||
dropFiles([
|
||||
makeFile(`${DATE}_2852_ephys.bin`, 10_000_000_000),
|
||||
makeFile(`${DATE}_2852_spikes.h5`, 10_000_000_000),
|
||||
makeFile(`${DATE}_2852_trials.csv`, 10_000_000),
|
||||
makeFile(`${DATE}_2852_log.csv`, 10_000_000),
|
||||
]);
|
||||
await waitFor(() => screen.getByTestId('save-btn'));
|
||||
fireEvent.click(screen.getByTestId('save-btn'));
|
||||
|
||||
await waitFor(() => expect(sessionFilesApi.create).toHaveBeenCalled());
|
||||
const [, files] = sessionFilesApi.create.mock.calls[0];
|
||||
expect(files.find((f) => f.filename.endsWith('.bin')).file_type).toBe('Binary');
|
||||
expect(files.find((f) => f.filename.endsWith('.h5')).file_type).toBe('HDF5');
|
||||
expect(files.filter((f) => f.filename.endsWith('.csv'))).toHaveLength(2);
|
||||
files.filter((f) => f.filename.endsWith('.csv')).forEach((f) => {
|
||||
expect(f.file_type).toBe('CSV');
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the drop preview after a successful save', async () => {
|
||||
sessionFilesApi.create.mockResolvedValue([makeSavedFile('f1', 's-a1', `${DATE}_2852_trials.csv`)]);
|
||||
render(<FileDropZone rows={ROWS} date={DATE} />);
|
||||
dropFiles([makeFile(`${DATE}_2852_trials.csv`, 1024)]);
|
||||
await waitFor(() => screen.getByTestId('save-btn'));
|
||||
fireEvent.click(screen.getByTestId('save-btn'));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('drop-preview')).not.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
it('shows an error message when create fails', async () => {
|
||||
sessionFilesApi.create.mockRejectedValue({ message: 'Server error' });
|
||||
render(<FileDropZone rows={ROWS} date={DATE} />);
|
||||
dropFiles([makeFile(`${DATE}_2852_trials.csv`, 1024)]);
|
||||
await waitFor(() => screen.getByTestId('save-btn'));
|
||||
fireEvent.click(screen.getByTestId('save-btn'));
|
||||
await waitFor(() => screen.getByText(/server error/i));
|
||||
});
|
||||
});
|
||||
|
||||
// ── Deleting saved files ───────────────────────────────────────────────────────
|
||||
|
||||
describe('deleting saved files', () => {
|
||||
it('removes a saved file when its ✕ is clicked', async () => {
|
||||
sessionFilesApi.list.mockImplementation((id) =>
|
||||
id === 's-a1'
|
||||
? Promise.resolve([makeSavedFile('f1', 's-a1', `${DATE}_2852_trials.csv`)])
|
||||
: Promise.resolve([])
|
||||
);
|
||||
render(<FileDropZone rows={ROWS} date={DATE} />);
|
||||
await waitFor(() => screen.getByText(`${DATE}_2852_trials.csv`));
|
||||
fireEvent.click(screen.getAllByText('✕')[0]);
|
||||
await waitFor(() =>
|
||||
expect(sessionFilesApi.delete).toHaveBeenCalledWith('f1')
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText(`${DATE}_2852_trials.csv`)).not.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import Modal from '../src/components/ui/Modal';
|
||||
|
||||
describe('Modal component', () => {
|
||||
it('does not render when isOpen is false', () => {
|
||||
render(<Modal isOpen={false} onClose={() => {}} title="Test"><p>content</p></Modal>);
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders title and children when open', () => {
|
||||
render(<Modal isOpen={true} onClose={() => {}} title="My Modal"><p>Hello</p></Modal>);
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
expect(screen.getByText('My Modal')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hello')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClose when close button is clicked', () => {
|
||||
const onClose = jest.fn();
|
||||
render(<Modal isOpen={true} onClose={onClose} title="T"><p>x</p></Modal>);
|
||||
fireEvent.click(screen.getByLabelText(/close modal/i));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onClose when Escape key is pressed', () => {
|
||||
const onClose = jest.fn();
|
||||
render(<Modal isOpen={true} onClose={onClose} title="T"><p>x</p></Modal>);
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, within } from '@testing-library/react';
|
||||
import { SubjectSidebar } from '../src/components/SubjectSidebar';
|
||||
|
||||
const LINES = [
|
||||
{ id: 'a1', name: 'Mouse-A1', group: 'Group A', color: '#111' },
|
||||
{ id: 'a2', name: 'Mouse-A2', group: 'Group A', color: '#222' },
|
||||
{ id: 'b1', name: 'Mouse-B1', group: 'Group B', color: '#333' },
|
||||
];
|
||||
|
||||
function setup(overrides = {}) {
|
||||
const props = {
|
||||
lines: LINES,
|
||||
hiddenSubjects: new Set(),
|
||||
onToggleSubject: jest.fn(),
|
||||
onToggleGroup: jest.fn(),
|
||||
onShowAll: jest.fn(),
|
||||
onHighlight: jest.fn(),
|
||||
...overrides,
|
||||
};
|
||||
render(<SubjectSidebar {...props} />);
|
||||
return props;
|
||||
}
|
||||
|
||||
it('renders each group header with its subject count', () => {
|
||||
setup();
|
||||
expect(screen.getByText('Group A')).toBeInTheDocument();
|
||||
expect(screen.getByText('Group B')).toBeInTheDocument();
|
||||
expect(screen.getByText('(2)')).toBeInTheDocument();
|
||||
expect(screen.getByText('(1)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a subject checkbox per subject, checked when visible', () => {
|
||||
setup({ hiddenSubjects: new Set(['a2']) });
|
||||
expect(screen.getByLabelText('Mouse-A1').checked).toBe(true);
|
||||
expect(screen.getByLabelText('Mouse-A2').checked).toBe(false);
|
||||
});
|
||||
|
||||
it('calls onToggleSubject with the id when a subject checkbox is clicked', () => {
|
||||
const { onToggleSubject } = setup();
|
||||
fireEvent.click(screen.getByLabelText('Mouse-A1'));
|
||||
expect(onToggleSubject).toHaveBeenCalledWith('a1');
|
||||
});
|
||||
|
||||
it('shows the group checkbox as indeterminate when the group is mixed', () => {
|
||||
setup({ hiddenSubjects: new Set(['a1']) });
|
||||
const groupCheckbox = screen.getByLabelText(/Group A/);
|
||||
expect(groupCheckbox.indeterminate).toBe(true);
|
||||
expect(groupCheckbox.checked).toBe(false);
|
||||
});
|
||||
|
||||
it('checks the group checkbox when all members are visible', () => {
|
||||
setup();
|
||||
expect(screen.getByLabelText(/Group A/).checked).toBe(true);
|
||||
});
|
||||
|
||||
it('calls onToggleGroup with the group ids and hidden=true when an all-visible group is clicked', () => {
|
||||
const { onToggleGroup } = setup();
|
||||
fireEvent.click(screen.getByLabelText(/Group A/));
|
||||
expect(onToggleGroup).toHaveBeenCalledWith(['a1', 'a2'], true);
|
||||
});
|
||||
|
||||
it('shows the group checkbox unchecked and not indeterminate when all members are hidden', () => {
|
||||
setup({ hiddenSubjects: new Set(['a1', 'a2']) });
|
||||
const groupCheckbox = screen.getByLabelText(/Group A/);
|
||||
expect(groupCheckbox.checked).toBe(false);
|
||||
expect(groupCheckbox.indeterminate).toBe(false);
|
||||
});
|
||||
|
||||
it('calls onToggleGroup with hidden=false when a fully-hidden group is clicked', () => {
|
||||
const { onToggleGroup } = setup({ hiddenSubjects: new Set(['a1', 'a2']) });
|
||||
fireEvent.click(screen.getByLabelText(/Group A/));
|
||||
expect(onToggleGroup).toHaveBeenCalledWith(['a1', 'a2'], false);
|
||||
});
|
||||
|
||||
it('hides the Show all link when nothing is hidden, shows it otherwise', () => {
|
||||
const { rerender } = render(
|
||||
<SubjectSidebar lines={LINES} hiddenSubjects={new Set()}
|
||||
onToggleSubject={() => {}} onToggleGroup={() => {}}
|
||||
onShowAll={() => {}} onHighlight={() => {}} />,
|
||||
);
|
||||
expect(screen.queryByText('Show all')).toBeNull();
|
||||
rerender(
|
||||
<SubjectSidebar lines={LINES} hiddenSubjects={new Set(['a1'])}
|
||||
onToggleSubject={() => {}} onToggleGroup={() => {}}
|
||||
onShowAll={() => {}} onHighlight={() => {}} />,
|
||||
);
|
||||
expect(screen.getByText('Show all')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onShowAll when the Show all link is clicked', () => {
|
||||
const { onShowAll } = setup({ hiddenSubjects: new Set(['a1']) });
|
||||
fireEvent.click(screen.getByText('Show all'));
|
||||
expect(onShowAll).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onHighlight on subject row hover enter/leave', () => {
|
||||
const { onHighlight } = setup();
|
||||
const row = screen.getByLabelText('Mouse-A1').closest('label');
|
||||
fireEvent.mouseEnter(row);
|
||||
expect(onHighlight).toHaveBeenCalledWith('a1');
|
||||
fireEvent.mouseLeave(row);
|
||||
expect(onHighlight).toHaveBeenCalledWith(null);
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
sortPayloadByValueDesc,
|
||||
toggleInSet,
|
||||
setIdsHidden,
|
||||
groupVisibilityState,
|
||||
groupLines,
|
||||
serializePlotConfig,
|
||||
readPlotConfig,
|
||||
} from '../src/lib/crossSubjectChart';
|
||||
|
||||
describe('sortPayloadByValueDesc', () => {
|
||||
it('sorts by value descending and drops null/undefined values', () => {
|
||||
const payload = [
|
||||
{ dataKey: 'a', value: 40 },
|
||||
{ dataKey: 'b', value: null },
|
||||
{ dataKey: 'c', value: 92 },
|
||||
{ dataKey: 'd', value: 78 },
|
||||
];
|
||||
expect(sortPayloadByValueDesc(payload).map((p) => p.dataKey)).toEqual(['c', 'd', 'a']);
|
||||
});
|
||||
it('returns [] for non-array input', () => {
|
||||
expect(sortPayloadByValueDesc(undefined)).toEqual([]);
|
||||
});
|
||||
it('does not mutate the input array', () => {
|
||||
const payload = [{ dataKey: 'a', value: 1 }, { dataKey: 'b', value: 2 }];
|
||||
sortPayloadByValueDesc(payload);
|
||||
expect(payload.map((p) => p.dataKey)).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleInSet', () => {
|
||||
it('adds a missing id and removes a present id, returning a new Set', () => {
|
||||
const a = new Set(['x']);
|
||||
const added = toggleInSet(a, 'y');
|
||||
expect([...added].sort()).toEqual(['x', 'y']);
|
||||
expect(added).not.toBe(a);
|
||||
const removed = toggleInSet(added, 'x');
|
||||
expect([...removed]).toEqual(['y']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setIdsHidden', () => {
|
||||
it('adds all ids when hidden=true', () => {
|
||||
expect([...setIdsHidden(new Set(['a']), ['b', 'c'], true)].sort()).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
it('removes all ids when hidden=false', () => {
|
||||
expect([...setIdsHidden(new Set(['a', 'b', 'c']), ['b', 'c'], false)]).toEqual(['a']);
|
||||
});
|
||||
it('returns a new Set', () => {
|
||||
const s = new Set();
|
||||
expect(setIdsHidden(s, ['a'], true)).not.toBe(s);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupVisibilityState', () => {
|
||||
it("returns 'all' when none of the ids are hidden", () => {
|
||||
expect(groupVisibilityState(['a', 'b'], new Set())).toBe('all');
|
||||
});
|
||||
it("returns 'none' when all ids are hidden", () => {
|
||||
expect(groupVisibilityState(['a', 'b'], new Set(['a', 'b']))).toBe('none');
|
||||
});
|
||||
it("returns 'some' when a subset is hidden", () => {
|
||||
expect(groupVisibilityState(['a', 'b'], new Set(['a']))).toBe('some');
|
||||
});
|
||||
it("returns 'none' for an empty group", () => {
|
||||
expect(groupVisibilityState([], new Set())).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupLines', () => {
|
||||
it('groups by .group preserving first-seen group order and within-group order', () => {
|
||||
const lines = [
|
||||
{ id: '1', group: 'B' },
|
||||
{ id: '2', group: 'A' },
|
||||
{ id: '3', group: 'B' },
|
||||
];
|
||||
const out = groupLines(lines);
|
||||
expect(out.map((g) => g.group)).toEqual(['B', 'A']);
|
||||
expect(out[0].subjects.map((s) => s.id)).toEqual(['1', '3']);
|
||||
});
|
||||
it("uses '—' for lines with no group", () => {
|
||||
expect(groupLines([{ id: '1' }])[0].group).toBe('—');
|
||||
});
|
||||
});
|
||||
|
||||
describe('serializePlotConfig', () => {
|
||||
const STATE = {
|
||||
xField: '__days__', groupBy: 'g1', tickStep: 2, metric: 'Success',
|
||||
hiddenSubjects: new Set(['b', 'a']), sidebarOpen: true,
|
||||
counts: { height: 220, yMin: '0', yMax: '', xZoomMin: '', xZoomMax: '', xFieldOverride: 'f2' },
|
||||
rate: { height: 160, yMin: '', yMax: '', xZoomMin: '', xZoomMax: '', xFieldOverride: null },
|
||||
};
|
||||
|
||||
it('produces a plain JSON object with hiddenSubjects as a sorted array', () => {
|
||||
const out = serializePlotConfig(STATE);
|
||||
expect(out.hiddenSubjects).toEqual(['a', 'b']);
|
||||
expect(out.sidebarOpen).toBe(true);
|
||||
expect(out.xField).toBe('__days__');
|
||||
expect(out.metric).toBe('Success');
|
||||
expect(out.counts).toEqual({ height: 220, yMin: '0', yMax: '', xZoomMin: '', xZoomMax: '', xFieldOverride: 'f2' });
|
||||
expect(out.rate.xFieldOverride).toBeNull();
|
||||
});
|
||||
|
||||
it('round-trips through readPlotConfig (values preserved)', () => {
|
||||
const back = readPlotConfig(serializePlotConfig(STATE));
|
||||
expect(back.xField).toBe('__days__');
|
||||
expect(back.groupBy).toBe('g1');
|
||||
expect(back.tickStep).toBe(2);
|
||||
expect(back.hiddenSubjects).toEqual(['a', 'b']);
|
||||
expect(back.sidebarOpen).toBe(true);
|
||||
expect(back.counts.height).toBe(220);
|
||||
expect(back.counts.xFieldOverride).toBe('f2');
|
||||
expect(back.rate.height).toBe(160);
|
||||
expect(back.rate.xFieldOverride).toBeNull();
|
||||
});
|
||||
|
||||
it('returns an empty hiddenSubjects array when state.hiddenSubjects is null', () => {
|
||||
expect(serializePlotConfig({ ...STATE, hiddenSubjects: null }).hiddenSubjects).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readPlotConfig', () => {
|
||||
it('returns safe defaults for null', () => {
|
||||
const c = readPlotConfig(null);
|
||||
expect(c.hiddenSubjects).toEqual([]);
|
||||
expect(c.sidebarOpen).toBe(false);
|
||||
expect(c.xField).toBeUndefined();
|
||||
expect(c.counts.yMin).toBe('');
|
||||
expect(c.counts.xFieldOverride).toBeNull();
|
||||
});
|
||||
|
||||
it('returns safe defaults for a non-object', () => {
|
||||
expect(readPlotConfig([1, 2]).hiddenSubjects).toEqual([]);
|
||||
expect(readPlotConfig('nope').sidebarOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('filters non-string hiddenSubjects and ignores wrong-typed fields', () => {
|
||||
const c = readPlotConfig({ hiddenSubjects: ['a', 5, 'b'], tickStep: 'x', sidebarOpen: 'yes' });
|
||||
expect(c.hiddenSubjects).toEqual(['a', 'b']);
|
||||
expect(c.tickStep).toBeUndefined();
|
||||
expect(c.sidebarOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('does not throw on malformed nested chart settings', () => {
|
||||
expect(() => readPlotConfig({ counts: 'bad', rate: [] })).not.toThrow();
|
||||
const c = readPlotConfig({ counts: 'bad' });
|
||||
expect(c.counts.yMin).toBe('');
|
||||
expect(c.counts.height).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,361 @@
|
||||
import {
|
||||
parseCSVHeaders,
|
||||
parseCSV,
|
||||
getUniqueNoteValues,
|
||||
runAnalysis,
|
||||
extractNumber,
|
||||
computeNumericSeries,
|
||||
} from '../src/lib/csvAnalysis';
|
||||
|
||||
// ── Fixtures ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// Minimal CSV mimicking the real experiment file structure
|
||||
const FIXTURE_CSV = `timestamp,frame_number,frame_line_status,note
|
||||
0.0,1,10,
|
||||
1.0,2,10,f
|
||||
2.0,3,10,s
|
||||
3.0,4,10,s
|
||||
4.0,5,10,f
|
||||
5.0,6,10,
|
||||
6.0,7,10,s
|
||||
7.0,8,10,f
|
||||
8.0,9,10,f
|
||||
9.0,10,10,s`;
|
||||
|
||||
// Same data but with Windows-style line endings
|
||||
const FIXTURE_CSV_CRLF = FIXTURE_CSV.replace(/\n/g, '\r\n');
|
||||
|
||||
// CSV with quoted field containing a comma
|
||||
const FIXTURE_CSV_QUOTED = `timestamp,note\n1.0,"hello, world"\n2.0,plain`;
|
||||
|
||||
// Out-of-order timestamps (should be sorted)
|
||||
const FIXTURE_UNSORTED = `timestamp,note
|
||||
3.0,s
|
||||
1.0,f
|
||||
2.0,s`;
|
||||
|
||||
const CLASSIFICATIONS = { f: 'Failure', s: 'Success' };
|
||||
|
||||
// ── parseCSVHeaders ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('parseCSVHeaders', () => {
|
||||
test('returns column names from first line', () => {
|
||||
expect(parseCSVHeaders(FIXTURE_CSV)).toEqual([
|
||||
'timestamp', 'frame_number', 'frame_line_status', 'note',
|
||||
]);
|
||||
});
|
||||
|
||||
test('handles CRLF line endings', () => {
|
||||
expect(parseCSVHeaders(FIXTURE_CSV_CRLF)).toEqual([
|
||||
'timestamp', 'frame_number', 'frame_line_status', 'note',
|
||||
]);
|
||||
});
|
||||
|
||||
test('handles single-line CSV (no newline)', () => {
|
||||
expect(parseCSVHeaders('a,b,c')).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
});
|
||||
|
||||
// ── parseCSV ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('parseCSV', () => {
|
||||
test('returns headers and rows', () => {
|
||||
const { headers, rows } = parseCSV(FIXTURE_CSV);
|
||||
expect(headers).toEqual(['timestamp', 'frame_number', 'frame_line_status', 'note']);
|
||||
expect(rows).toHaveLength(10);
|
||||
});
|
||||
|
||||
test('row values are keyed by header name', () => {
|
||||
const { rows } = parseCSV(FIXTURE_CSV);
|
||||
expect(rows[0]).toEqual({ timestamp: '0.0', frame_number: '1', frame_line_status: '10', note: '' });
|
||||
expect(rows[1]).toEqual({ timestamp: '1.0', frame_number: '2', frame_line_status: '10', note: 'f' });
|
||||
});
|
||||
|
||||
test('handles CRLF line endings', () => {
|
||||
const { rows } = parseCSV(FIXTURE_CSV_CRLF);
|
||||
expect(rows).toHaveLength(10);
|
||||
expect(rows[1].note).toBe('f');
|
||||
});
|
||||
|
||||
test('handles quoted fields containing commas', () => {
|
||||
const { rows } = parseCSV(FIXTURE_CSV_QUOTED);
|
||||
expect(rows[0].note).toBe('hello, world');
|
||||
expect(rows[1].note).toBe('plain');
|
||||
});
|
||||
|
||||
test('skips blank lines', () => {
|
||||
const csv = 'a,b\n1,2\n\n3,4\n';
|
||||
const { rows } = parseCSV(csv);
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getUniqueNoteValues ────────────────────────────────────────────────────────
|
||||
|
||||
describe('getUniqueNoteValues', () => {
|
||||
test('returns sorted unique non-empty values', () => {
|
||||
const { rows } = parseCSV(FIXTURE_CSV);
|
||||
expect(getUniqueNoteValues(rows, 'note')).toEqual(['f', 's']);
|
||||
});
|
||||
|
||||
test('ignores empty strings', () => {
|
||||
const rows = [{ note: '' }, { note: 'a' }, { note: '' }, { note: 'b' }];
|
||||
expect(getUniqueNoteValues(rows, 'note')).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
test('trims whitespace before deduplication', () => {
|
||||
const rows = [{ note: ' a ' }, { note: 'a' }, { note: 'b' }];
|
||||
expect(getUniqueNoteValues(rows, 'note')).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
test('returns empty array when no non-empty notes', () => {
|
||||
const rows = [{ note: '' }, { note: '' }];
|
||||
expect(getUniqueNoteValues(rows, 'note')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── runAnalysis ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('runAnalysis', () => {
|
||||
let result;
|
||||
|
||||
beforeEach(() => {
|
||||
const { rows } = parseCSV(FIXTURE_CSV);
|
||||
result = runAnalysis(rows, 'timestamp', 'note', CLASSIFICATIONS);
|
||||
});
|
||||
|
||||
// Fixture note sequence (sorted by timestamp, empty rows excluded):
|
||||
// t=1 f, t=2 s, t=3 s, t=4 f, t=6 s, t=7 f, t=8 f, t=9 s
|
||||
// Categories: F S S F S F F S
|
||||
|
||||
test('totalNoteRows counts only rows with non-empty note', () => {
|
||||
expect(result.totalNoteRows).toBe(8);
|
||||
});
|
||||
|
||||
test('sequence preserves order and maps to categories', () => {
|
||||
const notes = result.sequence.map(r => r.note);
|
||||
expect(notes).toEqual(['f', 's', 's', 'f', 's', 'f', 'f', 's']);
|
||||
|
||||
const cats = result.sequence.map(r => r.category);
|
||||
expect(cats).toEqual(['Failure', 'Success', 'Success', 'Failure', 'Success', 'Failure', 'Failure', 'Success']);
|
||||
});
|
||||
|
||||
test('categoryCounts totals are correct', () => {
|
||||
expect(result.categoryCounts['Failure'].total).toBe(4);
|
||||
expect(result.categoryCounts['Success'].total).toBe(4);
|
||||
});
|
||||
|
||||
test('categoryCounts byNote breakdown is correct', () => {
|
||||
expect(result.categoryCounts['Failure'].byNote).toEqual({ f: 4 });
|
||||
expect(result.categoryCounts['Success'].byNote).toEqual({ s: 4 });
|
||||
});
|
||||
|
||||
test('runs (run-length encoding) are correct', () => {
|
||||
// F S S F S F F S → F×1, S×2, F×1, S×1, F×2, S×1
|
||||
expect(result.runs).toEqual([
|
||||
{ category: 'Failure', length: 1 },
|
||||
{ category: 'Success', length: 2 },
|
||||
{ category: 'Failure', length: 1 },
|
||||
{ category: 'Success', length: 1 },
|
||||
{ category: 'Failure', length: 2 },
|
||||
{ category: 'Success', length: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('consecutiveStats totalRuns', () => {
|
||||
expect(result.consecutiveStats['Failure'].totalRuns).toBe(3);
|
||||
expect(result.consecutiveStats['Success'].totalRuns).toBe(3);
|
||||
});
|
||||
|
||||
test('consecutiveStats maxRun', () => {
|
||||
expect(result.consecutiveStats['Failure'].maxRun).toBe(2);
|
||||
expect(result.consecutiveStats['Success'].maxRun).toBe(2);
|
||||
});
|
||||
|
||||
test('consecutiveStats avgRun', () => {
|
||||
// Failure runs: [1,1,2] → avg = 4/3
|
||||
expect(result.consecutiveStats['Failure'].avgRun).toBeCloseTo(4 / 3);
|
||||
// Success runs: [2,1,1] → avg = 4/3
|
||||
expect(result.consecutiveStats['Success'].avgRun).toBeCloseTo(4 / 3);
|
||||
});
|
||||
|
||||
test('consecutiveStats runLengths array', () => {
|
||||
expect(result.consecutiveStats['Failure'].runLengths).toEqual([1, 1, 2]);
|
||||
expect(result.consecutiveStats['Success'].runLengths).toEqual([2, 1, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runAnalysis — sorting', () => {
|
||||
test('sorts rows by timestamp before building sequence', () => {
|
||||
const { rows } = parseCSV(FIXTURE_UNSORTED);
|
||||
// Input order: s(3), f(1), s(2) — sorted order: f(1), s(2), s(3)
|
||||
const result = runAnalysis(rows, 'timestamp', 'note', CLASSIFICATIONS);
|
||||
expect(result.sequence.map(r => r.note)).toEqual(['f', 's', 's']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runAnalysis — unclassified notes', () => {
|
||||
test('unclassified notes appear in sequence with null category', () => {
|
||||
const { rows } = parseCSV(FIXTURE_CSV);
|
||||
// Only classify 'f'; leave 's' unclassified
|
||||
const result = runAnalysis(rows, 'timestamp', 'note', { f: 'Failure' });
|
||||
const nullCats = result.sequence.filter(r => r.category === null);
|
||||
expect(nullCats.length).toBe(4); // 4 's' notes
|
||||
});
|
||||
|
||||
test('unclassified notes are excluded from categoryCounts', () => {
|
||||
const { rows } = parseCSV(FIXTURE_CSV);
|
||||
const result = runAnalysis(rows, 'timestamp', 'note', { f: 'Failure' });
|
||||
expect(result.categoryCounts['Success']).toBeUndefined();
|
||||
});
|
||||
|
||||
test('unclassified notes do not break or extend runs', () => {
|
||||
// Sequence with 'x' unclassified: f x f → should still be one Failure run of 2
|
||||
const csv = 'ts,note\n1.0,f\n2.0,x\n3.0,f';
|
||||
const { rows } = parseCSV(csv);
|
||||
// 'x' is unclassified, 'f' → Failure
|
||||
// After skipping 'x': f, f → one run of Failure×2
|
||||
const result = runAnalysis(rows, 'ts', 'note', { f: 'Failure' });
|
||||
expect(result.runs).toEqual([{ category: 'Failure', length: 2 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runAnalysis — multiple note values per category', () => {
|
||||
test('groups multiple note values under one category', () => {
|
||||
// The user's example: ss and s both belong to Success
|
||||
const csv = 'ts,note\n1.0,ss\n2.0,s\n3.0,s';
|
||||
const { rows } = parseCSV(csv);
|
||||
const result = runAnalysis(rows, 'ts', 'note', { ss: 'Success', s: 'Success' });
|
||||
expect(result.categoryCounts['Success'].total).toBe(3);
|
||||
expect(result.categoryCounts['Success'].byNote).toEqual({ ss: 1, s: 2 });
|
||||
});
|
||||
|
||||
test('run-length encoding with multiple note values in same category', () => {
|
||||
const csv = 'ts,note\n1.0,ss\n2.0,s\n3.0,f';
|
||||
const { rows } = parseCSV(csv);
|
||||
const result = runAnalysis(rows, 'ts', 'note', { ss: 'Success', s: 'Success', f: 'Failure' });
|
||||
// ss and s are both Success → run of 2, then Failure run of 1
|
||||
expect(result.runs).toEqual([
|
||||
{ category: 'Success', length: 2 },
|
||||
{ category: 'Failure', length: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runAnalysis — edge cases', () => {
|
||||
test('empty rows returns zero totals', () => {
|
||||
const result = runAnalysis([], 'timestamp', 'note', CLASSIFICATIONS);
|
||||
expect(result.totalNoteRows).toBe(0);
|
||||
expect(result.sequence).toEqual([]);
|
||||
expect(result.runs).toEqual([]);
|
||||
expect(result.categoryCounts).toEqual({});
|
||||
expect(result.consecutiveStats).toEqual({});
|
||||
});
|
||||
|
||||
test('all notes unclassified', () => {
|
||||
const { rows } = parseCSV(FIXTURE_CSV);
|
||||
const result = runAnalysis(rows, 'timestamp', 'note', {});
|
||||
expect(result.categoryCounts).toEqual({});
|
||||
expect(result.runs).toEqual([]);
|
||||
});
|
||||
|
||||
test('single note row', () => {
|
||||
const csv = 'ts,note\n1.0,f';
|
||||
const { rows } = parseCSV(csv);
|
||||
const result = runAnalysis(rows, 'ts', 'note', { f: 'Failure' });
|
||||
expect(result.totalNoteRows).toBe(1);
|
||||
expect(result.runs).toEqual([{ category: 'Failure', length: 1 }]);
|
||||
expect(result.consecutiveStats['Failure'].maxRun).toBe(1);
|
||||
expect(result.consecutiveStats['Failure'].avgRun).toBe(1);
|
||||
});
|
||||
|
||||
test('all same category', () => {
|
||||
const csv = 'ts,note\n1.0,f\n2.0,f\n3.0,f';
|
||||
const { rows } = parseCSV(csv);
|
||||
const result = runAnalysis(rows, 'ts', 'note', { f: 'Failure' });
|
||||
expect(result.runs).toEqual([{ category: 'Failure', length: 3 }]);
|
||||
expect(result.consecutiveStats['Failure'].maxRun).toBe(3);
|
||||
expect(result.consecutiveStats['Failure'].totalRuns).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractNumber', () => {
|
||||
test('extracts positive decimal from prefixed string', () => {
|
||||
expect(extractNumber('L25.5')).toBe(25.5);
|
||||
});
|
||||
test('extracts negative integer with letter prefix', () => {
|
||||
expect(extractNumber('R-12')).toBe(-12);
|
||||
});
|
||||
test('extracts integer from underscore-separated label', () => {
|
||||
expect(extractNumber('trial_007')).toBe(7);
|
||||
});
|
||||
test('does not parse scientific notation as one number', () => {
|
||||
// Accepted limitation: regex matches "3.2", strips "e2"
|
||||
expect(extractNumber('3.2e2')).toBe(3.2);
|
||||
});
|
||||
test('strips trailing units', () => {
|
||||
expect(extractNumber('42 ms')).toBe(42);
|
||||
});
|
||||
test('returns null for purely alphabetic string', () => {
|
||||
expect(extractNumber('abc')).toBeNull();
|
||||
});
|
||||
test('returns null for empty string', () => {
|
||||
expect(extractNumber('')).toBeNull();
|
||||
});
|
||||
test('returns null for null/undefined', () => {
|
||||
expect(extractNumber(null)).toBeNull();
|
||||
expect(extractNumber(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeNumericSeries', () => {
|
||||
const rows = [
|
||||
{ frame: '1', timestamp: '0.0', note: 'L25' },
|
||||
{ frame: '3', timestamp: '2.0', note: 'L30.5' },
|
||||
{ frame: '2', timestamp: '1.0', note: 'L40' },
|
||||
{ frame: '4', timestamp: '3.0', note: 'F' },
|
||||
{ frame: '5', timestamp: '4.0', note: 'abc' }, // bucket-member but no number
|
||||
{ frame: 'x', timestamp: '5.0', note: 'L99' }, // bad x
|
||||
{ frame: '6', timestamp: '6.0', note: '' }, // not in bucket
|
||||
];
|
||||
const bucket = { name: 'Lick latency', type: 'numerical', notes: ['L25', 'L30.5', 'L40', 'abc', 'L99'] };
|
||||
|
||||
test('returns sorted points with average and counts', () => {
|
||||
const out = computeNumericSeries(rows, 'frame', 'note', bucket);
|
||||
expect(out.points).toEqual([
|
||||
{ x: 1, value: 25, raw: 'L25' },
|
||||
{ x: 2, value: 40, raw: 'L40' },
|
||||
{ x: 3, value: 30.5, raw: 'L30.5' },
|
||||
]);
|
||||
expect(out.avg).toBeCloseTo((25 + 40 + 30.5) / 3);
|
||||
expect(out.skipped).toBe(2); // 'abc' (no number) + bad x
|
||||
expect(out.total).toBe(5); // bucket-member rows
|
||||
expect(out.xLabel).toBe('frame');
|
||||
});
|
||||
|
||||
test('falls back to timestamp x-column when frame is empty string', () => {
|
||||
const out = computeNumericSeries(rows, 'timestamp', 'note', bucket);
|
||||
expect(out.xLabel).toBe('timestamp');
|
||||
expect(out.points[0].x).toBe(0);
|
||||
});
|
||||
|
||||
test('returns avg=null and empty points when nothing plottable', () => {
|
||||
const onlyBad = [
|
||||
{ frame: '1', note: 'abc' },
|
||||
];
|
||||
const b = { name: 'Z', type: 'numerical', notes: ['abc'] };
|
||||
const out = computeNumericSeries(onlyBad, 'frame', 'note', b);
|
||||
expect(out.points).toEqual([]);
|
||||
expect(out.avg).toBeNull();
|
||||
expect(out.skipped).toBe(1);
|
||||
expect(out.total).toBe(1);
|
||||
});
|
||||
|
||||
test('returns total=0 when bucket notes match nothing in rows', () => {
|
||||
const b = { name: 'Empty', type: 'numerical', notes: ['nope'] };
|
||||
const out = computeNumericSeries(rows, 'frame', 'note', b);
|
||||
expect(out.total).toBe(0);
|
||||
expect(out.points).toEqual([]);
|
||||
expect(out.avg).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,308 @@
|
||||
import { extractValue } from '../src/lib/dataExport';
|
||||
import { listExportableParams } from '../src/lib/dataExport';
|
||||
|
||||
const status = {
|
||||
vitals: 'ok',
|
||||
custom_fields: { 'f-weight': 250 },
|
||||
analysis_summary: { total: 12, success_rate: 0.83, counts: { Success: 10, Failure: 2 } },
|
||||
};
|
||||
|
||||
describe('extractValue', () => {
|
||||
it('reads a builtin field via statusKey', () => {
|
||||
expect(extractValue(status, { kind: 'builtin', statusKey: 'vitals' })).toBe('ok');
|
||||
});
|
||||
it('reads a custom field via fieldId', () => {
|
||||
expect(extractValue(status, { kind: 'custom', fieldId: 'f-weight' })).toBe(250);
|
||||
});
|
||||
it('reads session-metric total', () => {
|
||||
expect(extractValue(status, { kind: 'total' })).toBe(12);
|
||||
});
|
||||
it('reads session-metric success_rate as a raw 0-1 fraction', () => {
|
||||
expect(extractValue(status, { kind: 'success_rate' })).toBe(0.83);
|
||||
});
|
||||
it('reads a dynamic count category', () => {
|
||||
expect(extractValue(status, { kind: 'category', category: 'Failure' })).toBe(2);
|
||||
});
|
||||
it('returns null when analysis_summary is absent', () => {
|
||||
expect(extractValue({ custom_fields: {} }, { kind: 'total' })).toBeNull();
|
||||
expect(extractValue({}, { kind: 'category', category: 'Success' })).toBeNull();
|
||||
});
|
||||
it('returns null for a missing custom field', () => {
|
||||
expect(extractValue({ custom_fields: {} }, { kind: 'custom', fieldId: 'nope' })).toBeNull();
|
||||
expect(extractValue({}, { kind: 'builtin', statusKey: 'notes' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
const dailyTemplate = [
|
||||
{ fieldId: 'b-vitals', key: 'vitals', label: 'Vitals', builtin: true, active: true },
|
||||
{ fieldId: 'c-weight', key: 'weight', label: 'Weight (g)', builtin: false, active: false },
|
||||
];
|
||||
const statuses = [
|
||||
{ analysis_summary: { counts: { Success: 1, Failure: 0 } } },
|
||||
{ analysis_summary: { counts: { Success: 2, Other: 1 } } },
|
||||
{ analysis_summary: null },
|
||||
];
|
||||
|
||||
describe('listExportableParams', () => {
|
||||
const params = listExportableParams(dailyTemplate, statuses);
|
||||
it('starts with the two fixed session metrics', () => {
|
||||
expect(params.slice(0, 2)).toEqual([
|
||||
expect.objectContaining({ id: '__total__', label: 'Total attempts', group: 'metrics', kind: 'total' }),
|
||||
expect.objectContaining({ id: '__success_rate__', label: 'Success rate', group: 'metrics', kind: 'success_rate' }),
|
||||
]);
|
||||
});
|
||||
it('adds one metrics entry per dynamic count category, sorted', () => {
|
||||
const cats = params.filter((p) => p.kind === 'category').map((p) => p.label);
|
||||
expect(cats).toEqual(['Failure', 'Other', 'Success']);
|
||||
});
|
||||
it('includes every template field (incl. inactive) in the daily group', () => {
|
||||
const daily = params.filter((p) => p.group === 'daily');
|
||||
expect(daily).toEqual([
|
||||
expect.objectContaining({ label: 'Vitals', kind: 'builtin', statusKey: 'vitals' }),
|
||||
expect.objectContaining({ label: 'Weight (g)', kind: 'custom', fieldId: 'c-weight' }),
|
||||
]);
|
||||
});
|
||||
it('gives every param a unique id', () => {
|
||||
const ids = params.map((p) => p.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
it('tolerates empty/missing inputs', () => {
|
||||
expect(listExportableParams(undefined, undefined).length).toBe(2); // just the two fixed metrics
|
||||
});
|
||||
});
|
||||
|
||||
import { toCSV, csvFilename } from '../src/lib/dataExport';
|
||||
|
||||
describe('toCSV', () => {
|
||||
it('emits context line, blank line, header, data (no group)', () => {
|
||||
const matrix = {
|
||||
corner: 'Date',
|
||||
context: { label: 'Parameter', value: 'Total attempts' },
|
||||
groupAxis: null,
|
||||
columns: [{ id: 'a1', label: 'Alpha', group: null }, { id: 'a2', label: 'Beta', group: null }],
|
||||
rows: [{ member: { label: '2026-07-01' }, values: [5, ''] }, { member: { label: '2026-07-02' }, values: ['', 9] }],
|
||||
};
|
||||
expect(toCSV(matrix)).toBe(
|
||||
'Parameter,Total attempts\n\nDate,Alpha,Beta\n2026-07-01,5,\n2026-07-02,,9',
|
||||
);
|
||||
});
|
||||
|
||||
it('adds a Group header row when subjects are columns', () => {
|
||||
const matrix = {
|
||||
corner: 'Date',
|
||||
context: { label: 'Parameter', value: 'Total attempts' },
|
||||
groupAxis: 'col',
|
||||
columns: [{ id: 'a1', label: 'Alpha', group: 'Control' }, { id: 'a2', label: 'Beta', group: 'Drug' }],
|
||||
rows: [{ member: { label: '2026-07-01' }, values: [5, 9] }],
|
||||
};
|
||||
expect(toCSV(matrix)).toBe(
|
||||
'Parameter,Total attempts\n\nGroup,Control,Drug\nDate,Alpha,Beta\n2026-07-01,5,9',
|
||||
);
|
||||
});
|
||||
|
||||
it('adds a leading Group column when subjects are rows', () => {
|
||||
const matrix = {
|
||||
corner: 'Subject',
|
||||
context: { label: 'Date', value: '2026-07-01' },
|
||||
groupAxis: 'row',
|
||||
columns: [{ id: 'p1', label: 'Total attempts', group: 'metrics' }],
|
||||
rows: [
|
||||
{ member: { label: 'Alpha', group: 'Control' }, values: [5] },
|
||||
{ member: { label: 'Beta', group: 'Drug' }, values: [9] },
|
||||
],
|
||||
};
|
||||
expect(toCSV(matrix)).toBe(
|
||||
'Date,2026-07-01\n\nGroup,Subject,Total attempts\nControl,Alpha,5\nDrug,Beta,9',
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes commas, quotes, and newlines per RFC 4180', () => {
|
||||
const matrix = {
|
||||
corner: 'Date',
|
||||
context: { label: 'Parameter', value: 'Note, field' },
|
||||
groupAxis: null,
|
||||
columns: [{ id: 'a1', label: 'he said "hi"', group: null }],
|
||||
rows: [{ member: { label: '2026-07-01' }, values: ['line1\nline2'] }],
|
||||
};
|
||||
expect(toCSV(matrix)).toBe(
|
||||
'Parameter,"Note, field"\n\nDate,"he said ""hi"""\n2026-07-01,"line1\nline2"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('csvFilename', () => {
|
||||
it('slugifies the experiment title and parameter label', () => {
|
||||
expect(csvFilename('My Study #1', 'Success rate')).toBe('My-Study-1-Success-rate.csv');
|
||||
});
|
||||
it('falls back to "export" when both slugs are empty', () => {
|
||||
expect(csvFilename('', '')).toBe('export.csv');
|
||||
});
|
||||
});
|
||||
|
||||
import { subjectGroupValue } from '../src/lib/dataExport';
|
||||
|
||||
describe('subjectGroupValue', () => {
|
||||
const animal = { animal_name: 'Alpha', animal_id_string: 'R-001', subject_info: { sex: 'M', cohort: '' } };
|
||||
it('returns null when no group field', () => {
|
||||
expect(subjectGroupValue(animal, '__none__')).toBeNull();
|
||||
expect(subjectGroupValue(animal, '')).toBeNull();
|
||||
});
|
||||
it('resolves name / id / subject_info fields', () => {
|
||||
expect(subjectGroupValue(animal, '__name__')).toBe('Alpha');
|
||||
expect(subjectGroupValue(animal, '__id__')).toBe('R-001');
|
||||
expect(subjectGroupValue(animal, 'sex')).toBe('M');
|
||||
});
|
||||
it('renders missing or empty values as an em dash', () => {
|
||||
expect(subjectGroupValue(animal, 'cohort')).toBe('—');
|
||||
expect(subjectGroupValue({}, 'sex')).toBe('—');
|
||||
});
|
||||
});
|
||||
|
||||
import { listSubjectMembers } from '../src/lib/dataExport';
|
||||
|
||||
describe('listSubjectMembers', () => {
|
||||
const animals = [
|
||||
{ id: 'a2', animal_name: 'Beta', animal_id_string: 'R-002', subject_info: { grp: 'Drug' } },
|
||||
{ id: 'a1', animal_name: 'Alpha', animal_id_string: 'R-001', subject_info: { grp: 'Control' } },
|
||||
{ id: 'a3', animal_name: 'Gamma', animal_id_string: 'R-003', subject_info: { grp: 'Control' } },
|
||||
];
|
||||
it('orders by name and carries group=null when no group field', () => {
|
||||
const m = listSubjectMembers(animals, '__none__');
|
||||
expect(m.map((s) => s.label)).toEqual(['Alpha', 'Beta', 'Gamma']);
|
||||
expect(m.every((s) => s.group === null)).toBe(true);
|
||||
expect(m[0]).toMatchObject({ id: 'a1', animalId: 'a1' });
|
||||
});
|
||||
it('clusters by group value then name when grouping is on', () => {
|
||||
const m = listSubjectMembers(animals, 'grp');
|
||||
expect(m.map((s) => [s.group, s.label])).toEqual([
|
||||
['Control', 'Alpha'], ['Control', 'Gamma'], ['Drug', 'Beta'],
|
||||
]);
|
||||
});
|
||||
it('disambiguates duplicate names with the id string', () => {
|
||||
const dup = [
|
||||
{ id: 'a1', animal_name: 'Rat', animal_id_string: 'R-001' },
|
||||
{ id: 'a2', animal_name: 'Rat', animal_id_string: 'R-002' },
|
||||
];
|
||||
expect(listSubjectMembers(dup, '__none__').map((s) => s.label)).toEqual(['Rat (R-001)', 'Rat (R-002)']);
|
||||
});
|
||||
});
|
||||
|
||||
import { numericFieldVal, listSampleMembers, buildSampleIndex } from '../src/lib/dataExport';
|
||||
|
||||
const sDaily = [{ fieldId: 'c-w', key: 'w', label: 'Weight', builtin: false, active: true }];
|
||||
const sStatuses = [
|
||||
{ animal_id: 'a1', date: '2026-07-01T00:00:00Z', custom_fields: { 'c-w': '250' } },
|
||||
{ animal_id: 'a1', date: '2026-07-03T00:00:00Z', custom_fields: { 'c-w': '260' } },
|
||||
{ animal_id: 'a2', date: '2026-07-01T00:00:00Z', custom_fields: { 'c-w': '250' } },
|
||||
];
|
||||
|
||||
describe('numericFieldVal', () => {
|
||||
it('reads builtin via key and custom via fieldId, parsed to number', () => {
|
||||
expect(numericFieldVal({ weight: '250' }, { builtin: true, key: 'weight' })).toBe(250);
|
||||
expect(numericFieldVal({ custom_fields: { 'c-w': '12.5' } }, { builtin: false, fieldId: 'c-w' })).toBe(12.5);
|
||||
});
|
||||
it('returns null for non-numeric, missing, or no field', () => {
|
||||
expect(numericFieldVal({ custom_fields: { 'c-w': 'abc' } }, { builtin: false, fieldId: 'c-w' })).toBeNull();
|
||||
expect(numericFieldVal({}, { builtin: true, key: 'weight' })).toBeNull();
|
||||
expect(numericFieldVal({ weight: 1 }, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listSampleMembers', () => {
|
||||
it('date: distinct dates sorted', () => {
|
||||
expect(listSampleMembers(sStatuses, '__date__', sDaily).map((m) => m.sampleValue)).toEqual(['2026-07-01', '2026-07-03']);
|
||||
});
|
||||
it('# days reach: 1..max records per subject', () => {
|
||||
expect(listSampleMembers(sStatuses, '__days__', sDaily).map((m) => m.sampleValue)).toEqual([1, 2]);
|
||||
});
|
||||
it('daily field: distinct numeric values sorted ascending', () => {
|
||||
expect(listSampleMembers(sStatuses, 'c-w', sDaily).map((m) => m.sampleValue)).toEqual([250, 260]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSampleIndex', () => {
|
||||
it('date coord maps animalId -> dateKey -> status', () => {
|
||||
const idx = buildSampleIndex(sStatuses, '__date__', sDaily);
|
||||
expect(idx.get('a1').get('2026-07-03').date).toContain('2026-07-03');
|
||||
});
|
||||
it('# days reach assigns per-subject ordinals in date order', () => {
|
||||
const idx = buildSampleIndex(sStatuses, '__days__', sDaily);
|
||||
expect(idx.get('a1').get(1).date).toContain('2026-07-01');
|
||||
expect(idx.get('a1').get(2).date).toContain('2026-07-03');
|
||||
expect(idx.get('a2').get(1).date).toContain('2026-07-01');
|
||||
expect(idx.get('a2').has(2)).toBe(false);
|
||||
});
|
||||
it('daily field maps numeric value to first status by date', () => {
|
||||
const idx = buildSampleIndex(sStatuses, 'c-w', sDaily);
|
||||
expect(idx.get('a1').get(250).date).toContain('2026-07-01');
|
||||
expect(idx.get('a1').get(260).date).toContain('2026-07-03');
|
||||
});
|
||||
it('# days reach counts per-status, not per-distinct-date (same-day statuses get separate ordinals)', () => {
|
||||
const sameDay = [
|
||||
{ animal_id: 'z1', date: '2026-07-01T08:00:00Z', analysis_summary: { total: 1 } },
|
||||
{ animal_id: 'z1', date: '2026-07-01T20:00:00Z', analysis_summary: { total: 2 } },
|
||||
];
|
||||
const idx = buildSampleIndex(sameDay, '__days__', []);
|
||||
expect(idx.get('z1').has(1)).toBe(true);
|
||||
expect(idx.get('z1').has(2)).toBe(true);
|
||||
expect(idx.get('z1').has(3)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
import { buildSubjectSeriesMatrix } from '../src/lib/dataExport';
|
||||
|
||||
const ssAnimals = [
|
||||
{ id: 'a1', animal_name: 'Alpha', animal_id_string: 'R-001', subject_info: { grp: 'Control' } },
|
||||
{ id: 'a2', animal_name: 'Beta', animal_id_string: 'R-002', subject_info: { grp: 'Drug' } },
|
||||
];
|
||||
const ssStatuses = [
|
||||
{ animal_id: 'a1', date: '2026-07-01T00:00:00Z', analysis_summary: { total: 5 } },
|
||||
{ animal_id: 'a1', date: '2026-07-02T00:00:00Z', analysis_summary: { total: 7 } },
|
||||
{ animal_id: 'a2', date: '2026-07-01T00:00:00Z', analysis_summary: { total: 9 } },
|
||||
];
|
||||
const totalParam = { id: '__total__', label: 'Total attempts', kind: 'total', group: 'metrics' };
|
||||
|
||||
describe('buildSubjectSeriesMatrix', () => {
|
||||
it('date X: subjects as columns, date rows, blank where missing, context names the metric', () => {
|
||||
const m = buildSubjectSeriesMatrix(
|
||||
{ xField: '__date__', dataParam: totalParam, groupField: '__none__' },
|
||||
{ statuses: ssStatuses, animals: ssAnimals, dailyTemplate: [] },
|
||||
);
|
||||
expect(m.corner).toBe('Date');
|
||||
expect(m.context).toEqual({ label: 'Data', value: 'Total attempts' });
|
||||
expect(m.groupAxis).toBeNull();
|
||||
expect(m.columns.map((c) => c.label)).toEqual(['Alpha', 'Beta']);
|
||||
expect(m.rows.map((r) => r.member.label)).toEqual(['2026-07-01', '2026-07-02']);
|
||||
expect(m.rows[0].values).toEqual([5, 9]);
|
||||
expect(m.rows[1].values).toEqual([7, '']);
|
||||
});
|
||||
it('# days reach X: rows align each subject by day ordinal', () => {
|
||||
const m = buildSubjectSeriesMatrix(
|
||||
{ xField: '__days__', dataParam: totalParam, groupField: '__none__' },
|
||||
{ statuses: ssStatuses, animals: ssAnimals, dailyTemplate: [] },
|
||||
);
|
||||
expect(m.corner).toBe('# Days Reach (ordinal)');
|
||||
expect(m.rows.map((r) => r.member.label)).toEqual(['1', '2']);
|
||||
expect(m.rows[0].values).toEqual([5, 9]);
|
||||
expect(m.rows[1].values).toEqual([7, '']);
|
||||
});
|
||||
it('grouping sets groupAxis col and clusters subjects', () => {
|
||||
const m = buildSubjectSeriesMatrix(
|
||||
{ xField: '__date__', dataParam: totalParam, groupField: 'grp' },
|
||||
{ statuses: ssStatuses, animals: ssAnimals, dailyTemplate: [] },
|
||||
);
|
||||
expect(m.groupAxis).toBe('col');
|
||||
expect(m.columns.map((c) => [c.group, c.label])).toEqual([['Control', 'Alpha'], ['Drug', 'Beta']]);
|
||||
});
|
||||
it('drops entirely-blank rows', () => {
|
||||
const statuses = [
|
||||
{ animal_id: 'a1', date: '2026-07-01T00:00:00Z', analysis_summary: { total: 5 } },
|
||||
{ animal_id: 'a1', date: '2026-07-05T00:00:00Z', analysis_summary: null },
|
||||
];
|
||||
const m = buildSubjectSeriesMatrix(
|
||||
{ xField: '__date__', dataParam: totalParam, groupField: '__none__' },
|
||||
{ statuses, animals: ssAnimals, dailyTemplate: [] },
|
||||
);
|
||||
expect(m.rows.map((r) => r.member.label)).toEqual(['2026-07-01']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
// Suppress noisy act() warnings from async state updates in useEffect hooks.
|
||||
// All assertions still run — this only silences the console.error spam that
|
||||
// causes OOM crashes when running the full suite.
|
||||
const originalError = console.error;
|
||||
beforeAll(() => {
|
||||
console.error = (...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('not wrapped in act')) return;
|
||||
originalError(...args);
|
||||
};
|
||||
});
|
||||
afterAll(() => { console.error = originalError; });
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user