Repository: shapiromatron/hawc (language: python)

Issue:
created new analytics pages
Created a single analytics page and 3 partial templates in HAWC to display assessment analytics:

- Analytics (`/mgmt/assessment/:id/analytics/`)
    - Time series
    - Time spent
    - Growth

These will continue to be built out to provide HAWC users with more "meta" information for what's occurring in a given assessment. Its purpose is to keep people motivated, report to management to understand what's currently going on, and show the scale of assessments, where are are questions about errors in extraction and relative magnitude.

Added behind a feature flag `HAWC_FEATURES.ENABLE_ANALYTICS = True` - defaults to False.

![assessment analytics](https://github.com/shapiromatron/hawc/assets/113325602/c70736f6-bfe6-454b-ab65-7e9d246f50f6)



Discussion / hints:

Fix patch:
diff --git a/hawc/apps/common/templates/common/analytics_plot.html b/hawc/apps/common/templates/common/analytics_plot.html
new file mode 100644
index 0000000000..f9fcb89f5a
--- /dev/null
+++ b/hawc/apps/common/templates/common/analytics_plot.html
@@ -0,0 +1,7 @@
+<div class="card box-shadow">
+    <div class="card-body">
+        <h3 class="text-center">{{ title }}</h3>
+        <div>{% plotly plot event="htmx" resizable="True" %}</div>
+        <p class="text-muted text-center"> {{ caption }}</p>
+    </div>
+</div>
diff --git a/hawc/apps/common/templatetags/bs4.py b/hawc/apps/common/templatetags/bs4.py
index 0d6b730012..35bf196c25 100644
--- a/hawc/apps/common/templatetags/bs4.py
+++ b/hawc/apps/common/templatetags/bs4.py
@@ -130,3 +130,17 @@ def add_class(value, css_class):
     else:
         return mark_safe(string.replace(">", f' class="{css_class}">'))
     return value
+
+
+@register.simple_tag
+def analytics_card(value, label):
+    return mark_safe(
+        f"""
+        <div class="card box-shadow">
+            <div class="card-body">
+                <h2 class="m-0 mt-1">{ value }</h2>
+                <p class="small">{ label }</p>
+            </div>
+        </div>
+        """
+    )
diff --git a/hawc/apps/mgmt/analytics/__init__.py b/hawc/apps/mgmt/analytics/__init__.py
index e69de29bb2..8b13789179 100644
--- a/hawc/apps/mgmt/analytics/__init__.py
+++ b/hawc/apps/mgmt/analytics/__init__.py
@@ -0,0 +1,1 @@
+
diff --git a/hawc/apps/mgmt/analytics/common.py b/hawc/apps/mgmt/analytics/common.py
new file mode 100644
index 0000000000..e0bee38cf7
--- /dev/null
+++ b/hawc/apps/mgmt/analytics/common.py
@@ -0,0 +1,40 @@
+import math
+
+import numpy as np
+import pandas as pd
+import plotly.express as px
+import plotly.graph_objects as go
+
+
+def percentage(numerator, denominator) -> float:
+    """Calculate a percentage; handles division by zero in denominator."""
+    try:
+        return numerator / float(denominator)
+    except ZeroDivisionError:
+        return 0
+
+
+def update_xscale(min_value: float, max_value: float) -> dict:
+    min_value = 1 if pd.isna(min_value) else min_value
+    max_value = 1 if pd.isna(max_value) else max_value
+    values = np.power(
+        10.0, range(math.floor(math.log10(min_value)), math.ceil(math.log10(max_value)) + 1)
+    )
+    return dict(
+        tickmode="array",
+        tickvals=values,
+        ticktext=[f"{value:,g}" for value in values],
+    )
+
+
+def empty_plot() -> go.Figure:
+    fig = px.line()
+    fig.update_layout(xaxis_title="", yaxis_title="")
+    fig.add_annotation(
+        text="No Data Available",
+        xref="paper",
+        yref="paper",
+        showarrow=False,
+        font=dict(size=15),
+    )
+    return fig
diff --git a/hawc/apps/mgmt/analytics/overall.py b/hawc/apps/mgmt/analytics/overall.py
index c340ac2668..9e6e3a7677 100644
--- a/hawc/apps/mgmt/analytics/overall.py
+++ b/hawc/apps/mgmt/analytics/overall.py
@@ -4,15 +4,9 @@
 from django.db.models import Count
 from django.utils import timezone
 
-logger = logging.getLogger(__name__)
-
+from .common import percentage
 
-def percentage(numerator, denominator) -> float:
-    """Calculate a percentage; handles division by zero in denominator."""
-    try:
-        return numerator / float(denominator)
-    except ZeroDivisionError:
-        return 0
+logger = logging.getLogger(__name__)
 
 
 def compute_object_counts() -> dict:
diff --git a/hawc/apps/mgmt/analytics/overview.py b/hawc/apps/mgmt/analytics/overview.py
new file mode 100644
index 0000000000..b7d787d4d8
--- /dev/null
+++ b/hawc/apps/mgmt/analytics/overview.py
@@ -0,0 +1,334 @@
+import pandas as pd
+import plotly.express as px
+import plotly.graph_objects as go
+from django.db.models import Count, Q
+
+from ...animal import constants
+from ...animal.models import Endpoint, EndpointGroup, Experiment
+from ...lit import constants as lc
+from ...lit.models import Reference, Search
+from ...study.models import Study
+from ...summary import constants as sc
+from ...summary.models import DataPivot, SummaryTable, Visual
+from .common import empty_plot, update_xscale
+
+
+# literature screening data
+def get_search_count(assessment_id):
+    return (
+        Search.objects.filter(assessment_id=assessment_id)
+        .exclude(source=lc.ReferenceDatabase.MANUAL)
+        .count()
+    )
+
+
+def get_search_source(assessment_id):
+    qs = Search.objects.filter(assessment_id=assessment_id)
+    return {
+        "searches": qs.filter(search_type=lc.SearchType.SEARCH).count(),
+        "imports": qs.filter(search_type=lc.SearchType.IMPORT)
+        .exclude(source=lc.ReferenceDatabase.MANUAL)
+        .count(),
+        "pubmed_imports": qs.filter(
+            search_type=lc.SearchType.IMPORT, source=lc.ReferenceDatabase.PUBMED
+        ).count(),
+        "hero_imports": qs.filter(
+            search_type=lc.SearchType.IMPORT, source=lc.ReferenceDatabase.HERO
+        ).count(),
+        "ris_imports": qs.filter(
+            search_type=lc.SearchType.IMPORT, source=lc.ReferenceDatabase.RIS
+        ).count(),
+    }
+
+
+def search_refs_df(assessment_id):
+    qs = (
+        Search.objects.filter(assessment_id=assessment_id)
+        .annotate(num_refs=Count("references"))
+        .values("created", "title", "num_refs")
+    )
+    df = pd.DataFrame(qs)
+    return df
+
+
+def refs_per_import_plot(assessent_id) -> go.Figure:
+    df = search_refs_df(assessent_id)
+    df2 = df.query("num_refs > 0")
+    if df2.empty:
+        return empty_plot()
+    fig = px.box(
+        data_frame=df2,
+        x="num_refs",
+        log_x=True,
+        points="all",
+        hover_name="title",
+        labels={"num_refs": "Number References"},
+    )
+    fig.layout["xaxis"].update(**update_xscale(df2.num_refs.min(), df2.num_refs.max()))
+    return fig
+
+
+def total_n_refs(assessment_id):
+    return Reference.objects.filter(assessment_id=assessment_id).count()
+
+
+def refs_by_year_df(assessment_id):
+    qs = (
+        Reference.objects.filter(assessment_id=assessment_id)
+        .values("year")
+        .annotate(nyear=Count("year"))
+        .order_by("year")
+    )
+    df = pd.DataFrame(qs, columns=["year", "nyear"])
+    return df
+
+
+def refs_by_year_plot(assessment_id) -> go.Figure:
+    df = refs_by_year_df(assessment_id)
+    df2 = df.dropna()
+    if df2.empty:
+        return empty_plot()
+    n_null = df.year.isna().count()
+    fig = px.area(
+        data_frame=df2,
+        x="year",
+        y="nyear",
+        log_y=True,
+        title=f"<sub>Total: {df.nyear.sum():,}   |   # Missing year: {n_null:,}</sub>",
+        labels={"year": "Year", "nyear": "#"},
+    )
+    fig.layout["yaxis"].update(**update_xscale(df2.nyear.min(), df2.nyear.max()))
+    return fig
+
+
+def n_tags_per_ref(assessment_id):
+    qs = (
+        Reference.objects.filter(assessment_id=assessment_id)
+        .values("id")
+        .annotate(ntags=Count("tags"))
+        .order_by("ntags")
+    )
+    df = pd.DataFrame(qs, columns=["id", "ntags"])
+    return df
+
+
+def ntags_nrefs_df(assessment_id):
+    df = n_tags_per_ref(assessment_id)
+    df2 = df.groupby("ntags").count().rename(columns={"id": "num_refs"}).reset_index()
+    return df2
+
+
+def ref_tags_breakdown(assessment_id):
+    df2 = ntags_nrefs_df(assessment_id)
+    no_tags = (
+        Reference.objects.filter(assessment_id=assessment_id)
+        .annotate(ntags=Count("tags"))
+        .filter(ntags=0)
+        .count()
+    )
+    with_tags = (
+        Reference.objects.filter(assessment_id=assessment_id)
+        .annotate(ntags=Count("tags"))
+        .filter(ntags__gt=0)
+        .count()
+    )
+    total_tags_applied = (df2.ntags * df2.num_refs).sum()
+    return dict(no_tags=no_tags, with_tags=with_tags, total_tags_applied=total_tags_applied)
+
+
+def refs_tags_plot(assessment_id):
+    df2 = ntags_nrefs_df(assessment_id)
+    if df2.empty:
+        return empty_plot()
+    fig = px.bar(
+        data_frame=df2,
+        y="ntags",
+        x="num_refs",
+        log_x=True,
+        orientation="h",
+        text_auto=True,
+        height=500,
+        labels={"num_refs": "Number of References", "ntags": "# Tags applied"},
+    )
+    fig.layout["xaxis"].update(**update_xscale(df2.num_refs.min(), df2.num_refs.max()))
+    return fig
+
+
+# studies data
+def study_classifications(assessment_id):
+    published_totals = (
+        Study.objects.filter(assessment_id=assessment_id)
+        .values("published")
+        .annotate(total=Count("published"))
+        .order_by("published")
+    )
+    try:
+        published_total = next(iter([x for x in published_totals if x["published"]]))["total"]
+    except StopIteration:
+        published_total = 0
+    try:
+        unpublished_total = next(iter([x for x in published_totals if not x["published"]]))["total"]
+    except StopIteration:
+        unpublished_total = 0
+    return {
+        "total": Study.objects.filter(assessment_id=assessment_id).count(),
+        "animal": Study.objects.filter(assessment_id=assessment_id)
+        .annotate(n_exp=Count("experiments"))
+        .filter(n_exp__gt=0)
+        .count(),
+        "epi": Study.objects.filter(assessment_id=assessment_id)
+        .annotate(n_sp=Count("study_populations"))
+        .filter(n_sp__gt=0)
+        .count(),
+        "epiv2": Study.objects.filter(assessment_id=assessment_id)
+        .annotate(n_des=Count("designs"))
+        .filter(n_des__gt=0)
+        .count(),
+        "rob": Study.objects.filter(assessment_id=assessment_id)
+        .annotate(n_rob=Count("riskofbiases"))
+        .filter(n_rob__gt=0)
+        .count(),
+        "published": published_total,
+        "unpublished": unpublished_total,
+    }
+
+
+# animal data
+def animal_group_df(assessment_id):
+    qs = Endpoint.objects.filter(assessment_id=assessment_id).values_list(
+        "animal_group__experiment__study_id",
+        "animal_group__experiment__id",
+        "animal_group_id",
+        "id",
+    )
+    df = pd.DataFrame(data=qs, columns=["study", "experiment", "animal_group", "endpoint"])
+    return df
+
+
+def animal_counts(assessment_id):
+    df = animal_group_df(assessment_id)
+    return {
+        "studies": df.study.nunique(),
+        "experiments": df.experiment.nunique(),
+        "animal_groups": df.animal_group.nunique(),
+        "endpoints": df.endpoint.nunique(),
+    }
+
+
+def endpoints_extracted(assessment_id):
+    return (
+        Endpoint.objects.filter(assessment_id=assessment_id)
+        .annotate(ngroups=Count("groups"))
+        .filter(data_extracted=True, ngroups__gt=0)
+        .count()
+    )
+
+
+def endpoints_ehv(assessment_id):
+    return (
+        Endpoint.objects.filter(assessment_id=assessment_id)
+        .filter(
+            Q(name_term__isnull=False)
+            | Q(system_term__isnull=False)
+            | Q(organ_term__isnull=False)
+            | Q(effect_term__isnull=False)
+            | Q(effect_subtype_term__isnull=False)
+        )
+        .count()
+    )
+
+
+def n_dose_response_groups(assessment_id):
+    return EndpointGroup.objects.filter(endpoint__assessment_id=assessment_id).count()
+
+
+def barchart_count_plot(df: pd.DataFrame, **kw):
+    return px.bar(data_frame=df, y="type", x="count", orientation="h", text_auto=True, **kw)
+
+
+def experiment_type_plot(assessment_id):
+    types = (
+        Experiment.objects.filter(study__assessment_id=assessment_id)
+        .values_list("type")
+        .annotate(total=Count("type"))
+        .order_by("type")
+    )
+    data = []
+    for type, n in list(types):
+        data.append((constants.ExperimentType(type).label, n))
+    df = pd.DataFrame(data=data, columns=["type", "count"]).sort_values("count")
+    if df.empty:
+        return empty_plot()
+    return barchart_count_plot(df, labels={"type": "Experiment type", "count": "# Experiments"})
+
+
+# summary data
+def summary_counts(assessment_id):
+    dp_count = DataPivot.objects.assessment_qs(assessment_id).count()
+
+    # visual by type
+    types = (
+        Visual.objects.assessment_qs(assessment_id)
+        .values_list("visual_type")
+        .annotate(total=Count("visual_type"))
+        .order_by("visual_type")
+    )
+    data = []
+    if dp_count:
+        data.append(("Data Pivot", dp_count))
+    for type, n in list(types):
+        data.append((sc.VisualType(type).label.title(), n))
+    df = pd.DataFrame(data=data, columns=["type", "count"]).sort_values("count")
+    visual_barchart = (
+        empty_plot()
+        if df["count"].sum() == 0
+        else barchart_count_plot(df, labels={"type": "Visual type", "count": "# Visuals"})
+    )
+
+    # tables by type
+    types = (
+        SummaryTable.objects.assessment_qs(assessment_id)
+        .values_list("table_type")
+        .annotate(total=Count("table_type"))
+        .order_by("table_type")
+    )
+    data = []
+    for type, n in list(types):
+        data.append((sc.TableType(type).label.title(), n))
+    df = pd.DataFrame(data=data, columns=["type", "count"]).sort_values("count")
+    table_barchart = (
+        empty_plot()
+        if df["count"].sum() == 0
+        else barchart_count_plot(df, labels={"type": "Table type", "count": "# Tables"})
+    )
+
+    return {
+        "visual": Visual.objects.assessment_qs(assessment_id).count(),
+        "datapivot": dp_count,
+        "table": SummaryTable.objects.assessment_qs(assessment_id).count(),
+        "visual_barchart": visual_barchart,
+        "table_barchart": table_barchart,
+    }
+
+
+def get_context_data(id: int) -> dict:
+    return {
+        # literature screening
+        "n": get_search_count(id),
+        "search_source": get_search_source(id),
+        "total_n_refs": total_n_refs(id),
+        "refs_per_import_plot": refs_per_import_plot(id),
+        "refs_by_year_plot": refs_by_year_plot(id),
+        "ref_tags_breakdown": ref_tags_breakdown(id),
+        "refs_tags_plot": refs_tags_plot(id),
+        # studies
+        "study_types": study_classifications(id),
+        # animal group
+        "animal_counts": animal_counts(id),
+        "n_endpoints_extracted": endpoints_extracted(id),
+        "n_endpoints_ehv": endpoints_ehv(id),
+        "n_dose_res_groups": n_dose_response_groups(id),
+        "experiment_type_plot": experiment_type_plot(id),
+        # summary
+        "summary_count": summary_counts(id),
+    }
diff --git a/hawc/apps/mgmt/analytics/time_series.py b/hawc/apps/mgmt/analytics/time_series.py
new file mode 100644
index 0000000000..f1565f8a52
--- /dev/null
+++ b/hawc/apps/mgmt/analytics/time_series.py
@@ -0,0 +1,48 @@
+import pandas as pd
+import plotly.express as px
+import plotly.graph_objects as go
+from django.db.models import Count
+from django.db.models.functions import Trunc
+
+from ...lit.models import Reference
+from ...riskofbias.models import RiskOfBias
+from ...study.models import Study
+from ...summary.models import DataPivot, Visual
+from .common import empty_plot
+
+
+def get_data(
+    Model, freq: str, assessment_id: int, filter_str: str = "assessment_id"
+) -> pd.DataFrame:
+    qs = (
+        Model.objects.all()
+        .filter(**{filter_str: assessment_id})
+        .annotate(date=Trunc("created", freq))
+        .order_by("date")
+        .values("date")
+        .annotate(n=Count("created"))
+    )
+    return pd.DataFrame(data=qs, columns=["date", "n"])
+
+
+def time_series(df: pd.DataFrame) -> go.Figure:
+    if df.empty:
+        return empty_plot()
+    df2 = df.set_index("date").cumsum().reset_index()
+    fig = px.line(df2, x="date", y="n")
+    return fig
+
+
+def get_context_data(id: int) -> dict:
+    viz_per_year = get_data(Visual, "month", id)
+    dp_per_year = get_data(DataPivot, "month", id)
+    return {
+        "refs": time_series(get_data(Reference, "month", id)),
+        "studies": time_series(get_data(Study, "month", id)),
+        "robs": time_series(get_data(RiskOfBias, "month", id, "study__assessment_id")),
+        "visuals": time_series(viz_per_year),
+        "datapivots": time_series(dp_per_year),
+        "total_visuals": time_series(
+            pd.concat([viz_per_year, dp_per_year]).groupby("date").sum().reset_index()
+        ),
+    }
diff --git a/hawc/apps/mgmt/analytics/time_spent.py b/hawc/apps/mgmt/analytics/time_spent.py
new file mode 100644
index 0000000000..9d1b6db862
--- /dev/null
+++ b/hawc/apps/mgmt/analytics/time_spent.py
@@ -0,0 +1,68 @@
+import pandas as pd
+import plotly.express as px
+from django.db.models import Value
+from django.db.models.functions import Concat
+
+from ...assessment.models import TimeSpentEditing
+from .common import update_xscale
+
+
+def time_spent_df(assessment_id: int) -> pd.DataFrame:
+    qs = TimeSpentEditing.objects.filter(seconds__gt=0, assessment_id=assessment_id).values_list(
+        "id",
+        "seconds",
+        "content_type__app_label",
+        Concat("content_type__app_label", Value("."), "content_type__model"),
+    )
+    df = pd.DataFrame(data=qs, columns="id|seconds|app|model".split("|")).sort_values(
+        ["app", "model"]
+    )
+    return df
+
+
+def time_spent_per_model_plot(df: pd.DataFrame):
+    fig = px.box(
+        data_frame=df,
+        y="model",
+        x="seconds",
+        log_x=True,
+        points="outliers",
+        height=600,
+    )
+    fig.layout["xaxis"].update(**update_xscale(df.seconds.min(), df.seconds.max()))
+    return fig
+
+
+def total_time_spent(df: pd.DataFrame) -> str:
+    hr = df.seconds.sum() / 60 / 60
+    if hr < 40:
+        return f"{hr:,.1f} hours"
+    elif hr < 200:
+        return f"{hr/8:,.1f} work days"
+    else:
+        return f"{hr/8/5:,.1f} work weeks"
+
+
+def time_tbl(df: pd.DataFrame) -> str:
+    return (
+        (df.groupby("model").seconds.sum() / 3600)
+        .reset_index()
+        .rename(columns={"seconds": "hours"})
+        .sort_values("hours", ascending=False)
+        .to_html(
+            index=False,
+            classes="table table-striped",
+            bold_rows=False,
+            float_format=lambda d: f"{d:0.2f}",
+            border=0,
+        )
+    )
+
+
+def get_context_data(id: int) -> dict:
+    df = time_spent_df(id)
+    return {
+        "time_spent_per_model_plot": time_spent_per_model_plot(df),
+        "total_time_spent": total_time_spent(df),
+        "time_spent_tbl": time_tbl(df),
+    }
diff --git a/hawc/apps/mgmt/templates/mgmt/analytics.html b/hawc/apps/mgmt/templates/mgmt/analytics.html
index 27b6b4a3eb..ce50338eaf 100644
--- a/hawc/apps/mgmt/templates/mgmt/analytics.html
+++ b/hawc/apps/mgmt/templates/mgmt/analytics.html
@@ -6,10 +6,10 @@ <h2>Assessment Analytics</h2>
 </div>
 <ul class="nav nav-tabs" hx-trigger="click" hx-swap="innerHTML">
   <li class="nav-item">
-    <button class="nav-link active" data-toggle="pill" data-target="#about">Home</button>
+    <button class="nav-link active" data-toggle="pill" data-target="#about">About</button>
   </li>
   <li class="nav-item">
-    <button class="nav-link" hx-get="?action=growth" data-toggle="pill" hx-target="#growth" data-target="#growth">Growth</button>
+    <button class="nav-link" hx-get="?action=overview" data-toggle="pill" hx-target="#overview" data-target="#overview">Overview</button>
   </li>
   <li class="nav-item">
     <button class="nav-link" hx-get="?action=time_series" data-toggle="pill" hx-target="#time_series" data-target="#time_series">Time Series</button>
@@ -20,9 +20,19 @@ <h2>Assessment Analytics</h2>
 </ul>
 <div class="tab-content mx-2 mt-2">
   <div class="tab-pane fade show active" id="about" role="tabpanel">
-    This content is available with the initial page load.
+    <div class="row">
+      <div class="col-xl-8">
+
+        <p class="mb-3">Assessment analytics give a high level overview about the shape and composition of data within a HAWC assessment. They include generated tables, plots, and summary statistics for counts of data types within an assessment, as well as time series and estimates of total time spent working on an adding and changing content within an assessment.</p>
+
+        {% alert info %}This is a work in progress; we'd appreciate feedback on the current content, as well as recommendations for other approaches; please <a href="{% url 'contact' %}">contact us</a> with any feedback!{% endalert %}
+
+        <p><b>Note:&nbsp;</b>These tabs are expensive to calculate and therefore are cached; check back every few hours to see updates.</p>
+
+      </div>
+    </div>
   </div>
-  <div class="tab-pane fade" id="growth" role="tabpanel">
+  <div class="tab-pane fade" id="overview" role="tabpanel">
     <p class="text-muted">Loading...</p>
   </div>
   <div class="tab-pane fade" id="time_series" role="tabpanel">
diff --git a/hawc/apps/mgmt/templates/mgmt/analytics/growth.html b/hawc/apps/mgmt/templates/mgmt/analytics/growth.html
deleted file mode 100644
index eb43e57c28..0000000000
--- a/hawc/apps/mgmt/templates/mgmt/analytics/growth.html
+++ /dev/null
@@ -1,1 +0,0 @@
-Assessment Growth
diff --git a/hawc/apps/mgmt/templates/mgmt/analytics/overview.html b/hawc/apps/mgmt/templates/mgmt/analytics/overview.html
new file mode 100644
index 0000000000..693f19d17a
--- /dev/null
+++ b/hawc/apps/mgmt/templates/mgmt/analytics/overview.html
@@ -0,0 +1,91 @@
+{% block content %}
+<div class="my-4">
+    <h3>Literature Tagging & Screening</h3>
+    <div class="card-columns">
+        {% analytics_card value=n label='total searches' %}
+        <div class="card box-shadow">
+            <div class="card-body">
+                <h4 class="card-title">Reference source types</h4>
+                <h2 class="m-0 mt-1">{{ search_source.imports }}</h2>
+                <p class="small">Added from Imports (PubMed/HERO/RIS)</p>
+                <p><b>{{ search_source.pubmed_imports }}</b> PubMed, <b>{{ search_source.hero_imports }}</b> HERO, <b>{{ search_source.ris_imports }}</b> RIS</p>
+                <h2 class="m-0 mt-1">{{ search_source.searches }}</h2>
+                <p class="small">Added from PubMed Keyword Searches</p>
+            </div>
+        </div>
+        {% analytics_card value=total_n_refs label='total references' %}
+        <div class="card box-shadow">
+            <div class="card-body">
+                <h4 class="card-title">Reference tags breakdown</h4>
+                <h2 class="m-0 mt-1">{{ ref_tags_breakdown.total_tags_applied }}</h2>
+                <p class="small">total tags applied</p>
+                <h2 class="m-0 mt-1">{{ ref_tags_breakdown.with_tags }}</h2>
+                <p class="small">references with tags</p>
+                <h2 class="m-0 mt-1">{{ ref_tags_breakdown.no_tags }}</h2>
+                <p class="small">references with no tags</p>
+            </div>
+        </div>
+    </div>
+    <div class="card-columns">
+        {% include 'common/analytics_plot.html' with title='Number of references per import' plot=refs_per_import_plot caption='Box plot displaying the number of references per import' %}
+        {% include 'common/analytics_plot.html' with title='References by publication year' plot=refs_by_year_plot %}
+        {% include 'common/analytics_plot.html' with title='References and tags plot' plot=refs_tags_plot %}
+    </div>
+</div>
+
+
+<div class="my-4">
+    <h3>Study</h3>
+    <div class="card-columns">
+        {% analytics_card value=study_types.total label='total studies' %}
+        <div class="card box-shadow">
+            <div class="card-body">
+                <h4 class="card-title">Published breakdown</h4>
+                <h2 class="m-0 mt-1">{{ study_types.published }}</h2>
+                <p class="small">published studies</p>
+                <h2 class="m-0 mt-1">{{ study_types.unpublished }}</h2>
+                <p class="small">unpublished studies</p>
+            </div>
+        </div>
+        <div class="card box-shadow">
+            <div class="card-body">
+                <h4 class="card-title">Study type breakdown</h4>
+                <h2 class="m-0 mt-1">{{ study_types.animal }}</h2>
+                <p class="small">animal studies</p>
+                <h2 class="m-0 mt-1">{{ study_types.epi }}</h2>
+                <p class="small">epi studies</p>
+                <h2 class="m-0 mt-1">{{ study_types.epiv2 }}</h2>
+                <p class="small">epiv2 studies</p>
+                <h2 class="m-0 mt-1">{{ study_types.rob }}</h2>
+                <p class="small">risk of bias studies</p>
+            </div>
+        </div>
+    </div>
+    <hr/>
+</div>
+
+<div class="my-4">
+    <h3>Animal Bioassay</h3>
+    <div class="card-columns">
+        {% analytics_card value=animal_counts.studies label='total studies' %}
+        {% analytics_card value=animal_counts.experiments label='total experiments' %}
+        {% analytics_card value=animal_counts.animal_groups label='total animal groups' %}
+        {% analytics_card value=animal_counts.endpoints label='total endpoints' %}
+        {% include 'common/analytics_plot.html' with title='Experiments by type' plot=experiment_type_plot %}
+        {% analytics_card value=n_endpoints_extracted label='endpoints with data extracted' %}
+        {% analytics_card value=n_endpoints_ehv label='endpoints using Environmental Health Vocabulary (EHV)' %}
+        {% analytics_card value=n_dose_res_groups label='total dose response groups' %}
+    </div>
+</div>
+
+<div class="my-4">
+    <h3>Summary Tables and Visuals</h3>
+    <div class="card-columns">
+        {% analytics_card value=summary_count.visual label='total visuals' %}
+        {% analytics_card value=summary_count.datapivot label='total data pivots' %}
+        {% analytics_card value=summary_count.table label='total tables' %}
+        {% include 'common/analytics_plot.html' with title='Visuals by type' plot=summary_count.visual_barchart %}
+        {% include 'common/analytics_plot.html' with title='Tables by type' plot=summary_count.table_barchart %}
+    </div>
+</div>
+{% endblock %}
diff --git a/hawc/apps/mgmt/templates/mgmt/analytics/time_series.html b/hawc/apps/mgmt/templates/mgmt/analytics/time_series.html
index d62bb08afa..20647b89dc 100644
--- a/hawc/apps/mgmt/templates/mgmt/analytics/time_series.html
+++ b/hawc/apps/mgmt/templates/mgmt/analytics/time_series.html
@@ -1,1 +1,12 @@
-Assessment Time Series
+{% block content %}
+<div class="row card-group">
+    {% include 'common/analytics_plot.html' with title='References created over time' plot=refs %}
+    {% include 'common/analytics_plot.html' with title='Studies created over time' plot=studies %}
+    {% include 'common/analytics_plot.html' with title='Risk Of Bias created over time' plot=robs %}
+</div>
+<div class="row card-group">
+    {% include 'common/analytics_plot.html' with title='Data pivots created over time' plot=datapivots %}
+    {% include 'common/analytics_plot.html' with title='Visuals created over time' plot=visuals %}
+    {% include 'common/analytics_plot.html' with title='Total visuals and data pivots over time' plot=total_visuals %}
+</div>
+{% endblock %}
diff --git a/hawc/apps/mgmt/templates/mgmt/analytics/time_spent.html b/hawc/apps/mgmt/templates/mgmt/analytics/time_spent.html
index e7103b5b7c..d074ce067c 100644
--- a/hawc/apps/mgmt/templates/mgmt/analytics/time_spent.html
+++ b/hawc/apps/mgmt/templates/mgmt/analytics/time_spent.html
@@ -1,1 +1,14 @@
-Assessment Time Spent
+{% block content %}
+    <div class="card-columns">
+        {% analytics_card value=total_time_spent label='spent on assessment' %}
+    </div>
+    {% include 'common/analytics_plot.html' with title='Time spent per content type' plot=time_spent_per_model_plot caption="Total time spent by content type for content objects within this assessment. This metric is imperfect; it is calculated by the total time the create and update forms are open for each content object.  This may overestimate time if a form is open but a user is not actively using it; or underestimate time where users are reviewing data in the read-only form. If content is missing or data appears inaccurate, please let us know, we'd like to improve this metric!" %}
+
+    <div class="card box-shadow">
+        <div class="card-body">
+            <h3 class="text-center">Total time per content type</h3>
+            {{time_spent_tbl|safe}}
+        </div>
+    </div>
+
+{% endblock %}
diff --git a/hawc/apps/mgmt/templates/mgmt/assessment_dashboard.html b/hawc/apps/mgmt/templates/mgmt/assessment_dashboard.html
index d5d707f18c..308d876681 100644
--- a/hawc/apps/mgmt/templates/mgmt/assessment_dashboard.html
+++ b/hawc/apps/mgmt/templates/mgmt/assessment_dashboard.html
@@ -6,9 +6,7 @@ <h2>Management dashboard</h2>
   {% actions %}
     <a class="dropdown-item" href="{% url 'mgmt:task-list' assessment.pk %}">Task list</a>
     <a class="dropdown-item" href="{% url 'mgmt:user-assessment-task-list' assessment.pk %}">My assigned tasks</a>
-    {% if feature_flags.ENABLE_ANALYTICS %}
     <a class="dropdown-item" href="{% url 'mgmt:assessment-analytics' assessment.pk %}">Analytics</a>
-    {% endif %}
   {% endactions %}
 </div>
 {% if object_list %}
diff --git a/hawc/apps/mgmt/urls.py b/hawc/apps/mgmt/urls.py
index 79d0854d3a..54c25f7878 100644
--- a/hawc/apps/mgmt/urls.py
+++ b/hawc/apps/mgmt/urls.py
@@ -1,4 +1,3 @@
-from django.conf import settings
 from django.urls import path
 
 from . import views
@@ -36,13 +35,9 @@
         {"action": "update"},
         name="task-update",
     ),
+    path(
+        "assessment/<int:pk>/analytics/",
+        views.AssessmentAnalytics.as_view(),
+        name="assessment-analytics",
+    ),
 ]
-
-if settings.HAWC_FEATURES.ENABLE_ANALYTICS:
-    urlpatterns += [
-        path(
-            "assessment/<int:pk>/analytics/",
-            views.AssessmentAnalytics.as_view(),
-            name="assessment-analytics",
-        ),
-    ]
diff --git a/hawc/apps/mgmt/views.py b/hawc/apps/mgmt/views.py
index a9ada2b189..13a8b87f82 100644
--- a/hawc/apps/mgmt/views.py
+++ b/hawc/apps/mgmt/views.py
@@ -7,13 +7,14 @@
 from ..assessment.constants import AssessmentViewPermissions
 from ..assessment.models import Assessment
 from ..common.crumbs import Breadcrumb
-from ..common.helper import WebappConfig
+from ..common.helper import WebappConfig, cacheable
 from ..common.htmx import HtmxGetMixin, HtmxViewSet, action, can_edit
 from ..common.views import BaseDetail, BaseFilterList, BaseList, FilterSetMixin, LoginRequiredMixin
 from ..myuser.models import HAWCUser
 from ..riskofbias.models import RiskOfBias
 from ..study.models import Study
 from . import constants, filterset, forms, models
+from .analytics import overview, time_series, time_spent
 
 
 def mgmt_dashboard_breadcrumb(assessment) -> Breadcrumb:
@@ -182,19 +183,31 @@ def get_context_data(self, **kwargs):
 class AssessmentAnalytics(HtmxGetMixin, BaseDetail):
     model = Assessment
     assessment_permission = AssessmentViewPermissions.TEAM_MEMBER
-    actions: set[str] = {"index", "time_series", "time_spent", "growth"}
+    actions: set[str] = {"index", "time_series", "time_spent", "overview"}
 
     def index(self, request: HttpRequest, context: dict):
         return render(request, "mgmt/analytics.html", context)
 
     def time_series(self, request: HttpRequest, context: dict):
+        context = cacheable(
+            lambda: time_series.get_context_data(self.assessment.id),
+            f"mgmt:analytics:time_series:{self.assessment.id}",
+        )
         return render(request, "mgmt/analytics/time_series.html", context)
 
     def time_spent(self, request: HttpRequest, context: dict):
+        context = cacheable(
+            lambda: time_spent.get_context_data(self.assessment.id),
+            f"mgmt:analytics:time_spent:{self.assessment.id}",
+        )
         return render(request, "mgmt/analytics/time_spent.html", context)
 
-    def growth(self, request: HttpRequest, context: dict):
-        return render(request, "mgmt/analytics/growth.html", context)
+    def overview(self, request: HttpRequest, context: dict):
+        context = cacheable(
+            lambda: overview.get_context_data(self.assessment.id),
+            f"mgmt:analytics:overview:{self.assessment.id}",
+        )
+        return render(request, "mgmt/analytics/overview.html", context)
 
 
 class TaskViewSet(HtmxViewSet):
diff --git a/hawc/constants.py b/hawc/constants.py
index 9895f2b187..260ace2a74 100644
--- a/hawc/constants.py
+++ b/hawc/constants.py
@@ -20,7 +20,6 @@ class FeatureFlags(BaseModel):
     ENABLE_PLOTLY_VISUAL: bool = False
     ENABLE_FILTER_DOWNLOADS: bool = False
     ENABLE_UDF: bool = False
-    ENABLE_ANALYTICS: bool = False
 
     @classmethod
     def from_env(cls, variable) -> "FeatureFlags":


Test patch:
diff --git a/hawc/main/settings/unittest.py b/hawc/main/settings/unittest.py
index 1d2859bc91..06c4ca66a9 100644
--- a/hawc/main/settings/unittest.py
+++ b/hawc/main/settings/unittest.py
@@ -11,7 +11,6 @@
 # enable feature flags for tests
 HAWC_FEATURES.ENABLE_BMDS_33 = True
 HAWC_FEATURES.ENABLE_UDF = True
-HAWC_FEATURES.ENABLE_ANALYTICS = True
 
 # remove toolbar for integration tests
 INSTALLED_APPS = [app for app in INSTALLED_APPS if app != "debug_toolbar"]
diff --git a/tests/hawc/apps/mgmt/test_views.py b/tests/hawc/apps/mgmt/test_views.py
index 96ac234236..d66635d734 100644
--- a/tests/hawc/apps/mgmt/test_views.py
+++ b/tests/hawc/apps/mgmt/test_views.py
@@ -93,7 +93,7 @@ def test_actions(self):
             "index": "mgmt/analytics.html",
             "time_series": "mgmt/analytics/time_series.html",
             "time_spent": "mgmt/analytics/time_spent.html",
-            "growth": "mgmt/analytics/growth.html",
+            "overview": "mgmt/analytics/overview.html",
         }
         assert set(AssessmentAnalytics.actions) == set(templates.keys())
         for action, expected_template in templates.items():