Repository: FlexMeasures/flexmeasures (language: python)

Issue:
Bokeh -> Altair
As seen in #404, pinning Bokeh to 1.0.4 is catching up on us.

We already deprecated much code which used Bokeh and instead started develping in Altair. It's time to make the next step. 
Roughly, this means retiring code like the analytics and portfolio views (which we are not linking right now) and switching one view (latest state) from Bokeh to Altair.

I grepped "bokeh" to make this list of modules which mention it:

- ui/views/new_dashboard.py (only packs CDN)
- ui/charts/latest_state.py (this needs a little work ― to rewrite `get_latest_power_as_plot` with Altair)
- ui/utils/view_utils.py (only for supporting packing Bokeh's CDN)
- ui/utils/plotting_utils.py (some research indicates we're not using all that code anymore, other than analytics.py and portfolio.py)



Discussion / hints:

Fix patch:
diff --git a/documentation/changelog.rst b/documentation/changelog.rst
index 5a4793f07..3646d0843 100644
--- a/documentation/changelog.rst
+++ b/documentation/changelog.rst
@@ -16,6 +16,8 @@ Bugfixes
 Infrastructure / Support
 ----------------------
 
+* Remove bokeh dependency and obsolete UI views [see `PR #476 <http://www.github.com/FlexMeasures/flexmeasures/pull/476>`_]
+
 
 
 v0.11.1 | September XX, 2022
diff --git a/flexmeasures/ui/__init__.py b/flexmeasures/ui/__init__.py
index 60fdd6204..396f55a32 100644
--- a/flexmeasures/ui/__init__.py
+++ b/flexmeasures/ui/__init__.py
@@ -1,7 +1,6 @@
 import os
 
 from flask import current_app, Flask, Blueprint
-from flask.blueprints import BlueprintSetupState
 from flask import send_from_directory
 from flask_security import login_required, roles_accepted
 import pandas as pd
@@ -22,7 +21,6 @@
     parse_config_entry_by_account_roles,
     find_first_applicable_config_entry,
 )
-from flexmeasures.api.v2_0 import flexmeasures_api as flexmeasures_api_v2_0
 
 # The ui blueprint. It is registered with the Flask app (see app.py)
 flexmeasures_ui = Blueprint(
@@ -67,21 +65,6 @@ def favicon():
     add_jinja_filters(app)
     add_jinja_variables(app)
 
-    # Add our chart endpoint to the Api 2.0 blueprint.
-    # This lets it show up in the API list twice, but that seems to be the best way for now (see below).
-    # Also, we'll reconsider where these charts endpoints should really live when we make more.
-    from flexmeasures.ui.views.charts import get_power_chart
-
-    # We cannot call this directly on the blueprint, as that only defers to registration.
-    # Re-registering the blueprint leads to all endpoints being listed twice.
-    blueprint_state = BlueprintSetupState(
-        flexmeasures_api_v2_0,
-        app,
-        {"url_prefix": "/api/v2_0"},
-        first_registration=False,
-    )
-    blueprint_state.add_url_rule("/charts/power", None, get_power_chart)
-
 
 def register_rq_dashboard(app):
     app.config.update(
diff --git a/flexmeasures/ui/crud/assets.py b/flexmeasures/ui/crud/assets.py
index 62cece23c..52072f72a 100644
--- a/flexmeasures/ui/crud/assets.py
+++ b/flexmeasures/ui/crud/assets.py
@@ -20,10 +20,8 @@
 )
 from flexmeasures.data.models.user import Account
 from flexmeasures.data.models.time_series import Sensor
-from flexmeasures.ui.charts.latest_state import get_latest_power_as_plot
 from flexmeasures.ui.utils.view_utils import render_flexmeasures_template
 from flexmeasures.ui.crud.api_wrapper import InternalApi
-from flexmeasures.utils.unit_utils import is_power_unit
 
 
 """
@@ -259,14 +257,11 @@ def get(self, id: str):
         asset = process_internal_api_response(asset_dict, int(id), make_obj=True)
         asset_form.process(data=process_internal_api_response(asset_dict))
 
-        latest_measurement_time_str, asset_plot_html = _get_latest_power_plot(asset)
         return render_flexmeasures_template(
             "crud/asset.html",
             asset=asset,
             asset_form=asset_form,
             msg="",
-            latest_measurement_time_str=latest_measurement_time_str,
-            asset_plot_html=asset_plot_html,
             mapboxAccessToken=current_app.config.get("MAPBOX_ACCESS_TOKEN", ""),
             user_can_create_assets=user_can_create_assets(),
             user_can_delete_asset=user_can_delete(asset),
@@ -337,9 +332,6 @@ def post(self, id: str):
             asset_form = with_options(AssetForm())
             if not asset_form.validate_on_submit():
                 asset = GenericAsset.query.get(id)
-                latest_measurement_time_str, asset_plot_html = _get_latest_power_plot(
-                    asset
-                )
                 # Display the form data, but set some extra data which the page wants to show.
                 asset_info = asset_form.to_json()
                 asset_info["id"] = id
@@ -352,8 +344,6 @@ def post(self, id: str):
                     asset_form=asset_form,
                     asset=asset,
                     msg="Cannot edit asset.",
-                    latest_measurement_time_str=latest_measurement_time_str,
-                    asset_plot_html=asset_plot_html,
                     mapboxAccessToken=current_app.config.get("MAPBOX_ACCESS_TOKEN", ""),
                     user_can_create_assets=user_can_create_assets(),
                     user_can_delete_asset=user_can_delete(asset),
@@ -379,14 +369,11 @@ def post(self, id: str):
                 )
                 asset = GenericAsset.query.get(id)
 
-        latest_measurement_time_str, asset_plot_html = _get_latest_power_plot(asset)
         return render_flexmeasures_template(
             "crud/asset.html",
             asset=asset,
             asset_form=asset_form,
             msg=msg,
-            latest_measurement_time_str=latest_measurement_time_str,
-            asset_plot_html=asset_plot_html,
             mapboxAccessToken=current_app.config.get("MAPBOX_ACCESS_TOKEN", ""),
             user_can_create_assets=user_can_create_assets(),
             user_can_delete_asset=user_can_delete(asset),
@@ -441,19 +428,3 @@ def _set_asset_type(
     else:
         current_app.logger.error(asset_type_error)
     return asset_type, asset_type_error
-
-
-def _get_latest_power_plot(asset: GenericAsset) -> Tuple[str, str]:
-    power_sensor: Optional[Sensor] = None
-    if asset._sa_instance_state.transient:
-        sensors = Sensor.query.filter(Sensor.generic_asset_id == asset.id).all()
-    else:
-        sensors = asset.sensors
-    for sensor in sensors:
-        if is_power_unit(sensor.unit):
-            power_sensor = sensor
-            break
-    if power_sensor is None:
-        return "", ""
-    else:
-        return get_latest_power_as_plot(power_sensor)
diff --git a/flexmeasures/ui/templates/base.html b/flexmeasures/ui/templates/base.html
index dc6dbedaa..4dcd62078 100644
--- a/flexmeasures/ui/templates/base.html
+++ b/flexmeasures/ui/templates/base.html
@@ -35,10 +35,6 @@
         rel="stylesheet" />
     <!-- Custom CSS -->
     <link href="{{ url_for('flexmeasures_ui.static', filename='css/flexmeasures.css') }}" rel="stylesheet" />
-    {% if contains_plots %}
-    {{ bokeh_css_resources|indent(4)|safe }}
-    {{ bokeh_js_resources|indent(4)|safe }}
-    {% endif %}
     {% if active_page == "tasks" %}
     <link href="{{ url_for('flexmeasures_ui.static', filename='css/external/rq-dashboard-bootstrap.min.css') }}"
         rel="stylesheet" />
@@ -232,7 +228,7 @@
     {% block attributions %}
     <div id="att-text" style="display: none;">
         <ul>
-            <li>Plots made with <a href="https://bokeh.pydata.org">Bokeh</a>.</li>
+            <li>Plots made with <a href="https://vega.github.io/vega-lite/">Vega-Lite</a>.</li>
             <li>Icons made by <a href="https://freepik.com">Freepik</a>, <a
                     href="https://www.flaticon.com/authors/tomas-knop" title="Tomas Knop">Tomas Knop</a>, <a
                     href="https://www.flaticon.com/authors/gregor-cresnar" title="Gregor Cresnar">Gregor Cresnar</a> and
diff --git a/flexmeasures/ui/templates/defaults.jinja b/flexmeasures/ui/templates/defaults.jinja
index 2cca8b284..7c9f0d61f 100644
--- a/flexmeasures/ui/templates/defaults.jinja
+++ b/flexmeasures/ui/templates/defaults.jinja
@@ -12,8 +12,6 @@
     "dashboard": dict(title="Dashboard", icon="dashboard"),
     "assets": dict(title="Assets", icon="list-ul"),
     "users": dict(title="Users", icon="users"),
-    "portfolio": dict(title="Portfolio overview", icon="briefcase"),
-    "analytics": dict(title="Analytics", icon="bar-chart"),
     "upload": dict(title="Upload data", icon="cloud-upload"),
     "control": dict(title="Flexibility actions", icon="wrench")
 }
diff --git a/flexmeasures/ui/templates/views/analytics.html b/flexmeasures/ui/templates/views/analytics.html
deleted file mode 100644
index 10591e128..000000000
--- a/flexmeasures/ui/templates/views/analytics.html
+++ /dev/null
@@ -1,312 +0,0 @@
-{% extends "base.html" %}
-
-{% set active_page = "analytics" %}
-
-{% block title %} Client analytics {% endblock %}
-
-{% block divs %}
-
-<div class="container-fluid">
-    <div class="row">
-        <div class="col-sm-4 top-bottom">
-            <div class="row">
-                <div class="col-sm-12">
-                    <h3>About</h3>
-                    <div class="justify">
-                        <p>
-                            This page provides in-depth analytics for your resources (individual assets or groups of
-                            assets).
-                            You can browse through historical data and assess how the platform is monitoring and
-                            forecasting data streams from various resources.
-                        </p>
-                        {% if documentation_exists %}
-                        <p>
-                            Need help understanding this page? Check out the
-                            <a href="{{ url_for('flexmeasures_ui.static', filename='documentation/html/views/analytics.html') }}"
-                                target="_blank">documentation</a>.
-                        </p>
-                        {% endif %}
-                    </div>
-                    <h3>Controls</h3>
-                </div>
-            </div>
-            <div class="form-group row">
-                <div class="col-sm-2">
-                    {% for asset_type in asset_types %}
-                    <div class="col-sm-1"><i class="{{  asset_type.name | asset_icon }} center-icon"></i></div>
-                    {% endfor %}
-                </div>
-                <div class="col-sm-10">
-                    <form id="resource-form" method="post">
-                        <label class="control-label" for="resource">Resource</label>
-                        <select class="form-control" id="resource" name="resource" onchange="submit_resource()">
-                            {% for asset_group_name in asset_group_names %}
-                            <option value="{{ asset_group_name }}" {% if selected_resource.name==asset_group_name %}
-                                selected="selected" {% endif %}>
-                                {{ asset_group_name | capitalize}}
-                            </option>
-                            {% endfor %}
-                            {% for asset in assets %}
-                            <option value="{{ asset.name }}" {% if selected_resource.name==asset.name %}
-                                selected="selected" {% endif %}>
-                                {{ asset.display_name | capitalize }} ({{ asset.asset_type.display_name }})
-                            </option>
-                            {% endfor %}
-                        </select>
-                        {% if offer_showing_individual_traces %}
-                        <div>
-                            <label class="radio-inline"><input type="radio" name="showing_individual_traces_for"
-                                    value="none" onchange="submit_resource()"
-                                    {% if showing_individual_traces_for == 'none' %} checked="checked" {% endif %}>
-                                <span data-toggle="tooltip" data-placement="bottom" data-container="body"
-                                    title="Show aggregated power, forecast and schedule values (default setting).">
-                                    Aggregated
-                                </span>
-                            </label>
-                            <label class="radio-inline"><input type="radio" name="showing_individual_traces_for"
-                                    value="power" onchange="submit_resource()"
-                                    {% if showing_individual_traces_for == 'power' %} checked="checked" {% endif %}>
-                                <span data-toggle="tooltip" data-placement="bottom" data-container="body"
-                                    title="Show individual traces of power measurements per involved asset (will not show forecasts and schedules).">
-                                    Power
-                                </span>
-                            </label>
-                            <label class="radio-inline"><input type="radio" name="showing_individual_traces_for"
-                                    value="schedules" onchange="submit_resource()"
-                                    {% if showing_individual_traces_for == 'schedules' %} checked="checked" {% endif %}>
-                                <span data-toggle="tooltip" data-placement="bottom" data-container="body"
-                                    title="Show individual traces of power schedules per involved asset (will not show power measurements and forecasts).">
-                                    Schedules
-                                </span>
-                            </label>
-                        </div>
-                        {% endif %}
-                    </form>
-                </div>
-            </div>
-            <div class="form-group row">
-                <div class="col-sm-2">
-                    <div class="col-sm-1"><i class="icon-market-{{ selected_market.unit[:3] }} center-icon"></i></div>
-                </div>
-                <div class="col-sm-10">
-                    <form id="market-form" method="post">
-                        <label class="control-label" for="market">Market</label>
-                        <select class="form-control" id="market" name="market" onchange="submit_market()">
-                            {% for market in markets %}
-                            <option value="{{ market.name }}" {% if selected_market.name==market.name %}
-                                selected="selected" {% endif %}>
-                                {{ market.display_name }}
-                            </option>
-                            {% endfor %}
-                        </select>
-                    </form>
-                </div>
-            </div>
-            <div class="form-group row">
-                <div class="col-sm-2">
-                    {% for sensor_type in sensor_types %}
-                    <div class="col-sm-1"><i class="{{ sensor_type.name | asset_icon }} center-icon"></i></div>
-                    {% endfor %}
-                </div>
-                <div class="col-sm-10">
-                    <form id="sensor_type-form" method="post">
-                        <label class="control-label" for="sensor_type">Weather</label>
-                        <select class="form-control" id="sensor_type" name="sensor_type"
-                            onchange="submit_sensor_type()">
-                            {% for sensor_type in sensor_types %}
-                            <option value="{{ sensor_type.name }}" {% if selected_sensor_type.name==sensor_type.name %}
-                                selected="selected" {% endif %}>
-                                {{ sensor_type.display_name | capitalize }}
-                            </option>
-                            {% endfor %}
-                        </select>
-                    </form>
-                </div>
-            </div>
-            {% block datetimepicker %} {{ super() }} {% endblock %}
-            {% block forecastpicker %} {{ super() }} {% endblock %}
-            <h3>Metrics</h3>
-            <table class="table text-right" style="font-size: 100%;">
-                <thead>
-                    <tr>
-                        <th rowspan="2"><i class="icon-calculator icon-center"></i></th>
-                        <th class="text-right">
-                            <span
-                                title="Total (aggregated) {% if show_consumption_as_positive %}consumption{% else %}production{% endif %} of {{ selected_resource.display_name }}">
-                                {% if show_consumption_as_positive %} Consumption
-                                {% else %} Production {% endif %}
-                            </span>
-                        </th>
-                        <th class="text-right"><span
-                                title="Average price for {{ selected_market.display_name }}">Price</span></th>
-                        <th class="text-right">
-                            {% if showing_pure_consumption_data %}
-                            <span title="The costs which {{ selected_resource.display_name }} accrued or might accrue">
-                                Costs</span>
-                            {% elif showing_pure_production_data %}
-                            <span
-                                title="The revenues which {{ selected_resource.display_name }} accrued or might accrue">
-                                Revenues</span>
-                            {% elif show_consumption_as_positive %}
-                            <span
-                                title="The costs or revenues which {{ selected_resource.display_name }} accrued or might accrue">
-                                Costs/Rev.</span>
-                            {% else %}
-                            <span
-                                title="The revenues or costs which {{ selected_resource.display_name }} accrued or might accrue"
-                                Rev./Costs</span>
-                                {% endif %}
-                        </th>
-                        {% if selected_sensor %}
-                        <th class="text-right"><span
-                                title="Selected weather sensor for {{ selected_resource.display_name }}">{{ selected_sensor_type.display_name | capitalize }}</span>
-                        </th>
-                        {% else %}
-                        <th class="text-right"><span
-                                title="No weather sensor for {{ selected_resource.display_name }} could be found."></span>
-                        </th>
-                        {% endif %}
-                    </tr>
-                    <tr>
-                        <th class="text-right">MWh</th>
-                        <th class="text-right">{{ selected_market.unit }}</th>
-                        <th class="text-right">{{ selected_market.unit[:3] }}</th>
-                        <th class="text-right">{{ selected_sensor.unit }}</th>
-                    </tr>
-                </thead>
-                <tbody>
-                    <tr>
-                        <td class="text-left">
-                            <span title="Most recent belief about the realisation">
-                                Realised
-                            </span>
-                        </td>
-                        <td>{{ "{:,.3f}".format( metrics["realised_power_in_mwh"] ) | hide_nan_if_desired }}</td>
-                        <td>{{ "{:,.2f}".format( metrics["realised_unit_price"] ) | hide_nan_if_desired }}</td>
-                        <td>{{ "{:,.2f}".format( metrics["realised_revenues_costs"] ) | hide_nan_if_desired }}</td>
-                        {% if metrics["realised_weather"] %}
-                        <td>{{ "{:,.3f}".format( metrics["realised_weather"] ) | hide_nan_if_desired }}</td>
-                        {% else %}
-                        <td></td>
-                        {% endif %}
-                    </tr>
-                    <tr>
-                        <td class="text-left">
-                            <span title="Expectation {{ horizon_human }} before realisation">
-                                Expected
-                            </span>
-                        </td>
-                        <td>{{ "{:,.3f}".format( metrics["expected_power_in_mwh"] ) | hide_nan_if_desired }}</td>
-                        <td>{{ "{:,.2f}".format( metrics["expected_unit_price"] ) | hide_nan_if_desired }}</td>
-                        <td>{{ "{:,.2f}".format( metrics["expected_revenues_costs"] ) | hide_nan_if_desired }}</td>
-                        {% if metrics["expected_weather"] %}
-                        <td>{{ "{:,.3f}".format( metrics["expected_weather"] ) | hide_nan_if_desired }}</td>
-                        {% else %}
-                        <td></td>
-                        {% endif %}
-                    </tr>
-                    <tr>
-                        <td class="text-left">
-                            <span title="Mean absolute error for {{ resolution_human }} values">
-                                MAE
-                            </span>
-                        </td>
-                        <td>{{ "{:,.3f}".format( metrics["mae_power_in_mwh"] ) | hide_nan_if_desired }}</td>
-                        <td>{{ "{:,.2f}".format( metrics["mae_unit_price"] ) | hide_nan_if_desired }}</td>
-                        <td>{{ "{:,.2f}".format( metrics["mae_revenues_costs"] ) | hide_nan_if_desired }}</td>
-                        {% if metrics["mae_weather"] %}
-                        <td>{{ "{:,.3f}".format( metrics["mae_weather"] ) | hide_nan_if_desired }}</td>
-                        {% else %}
-                        <td></td>
-                        {% endif %}
-                    </tr>
-                    <tr>
-                        <td class="text-left">
-                            <span title="Weighted absolute percentage error">
-                                WAPE
-                            </span>
-                        </td>
-                        <td>{{ "{:,.0%}".format( metrics["wape_power"] ) | hide_nan_if_desired }}</td>
-                        <td>{{ "{:,.0%}".format( metrics["wape_unit_price"] ) | hide_nan_if_desired }}</td>
-                        <td>{{ "{:,.0%}".format( metrics["wape_revenues_costs"] ) | hide_nan_if_desired }}</td>
-                        {% if metrics["wape_weather"] %}
-                        <td>{{ "{:,.0%}".format( metrics["wape_weather"] ) | hide_nan_if_desired }}</td>
-                        {% else %}
-                        <td></td>
-                        {% endif %}
-                    </tr>
-                </tbody>
-            </table>
-            <div class="row">
-                <div class="col-sm-6">
-                    {% if not current_user.has_role('anonymous') %}
-                    <form action="analytics_data/metrics/csv" method="get">
-                        <input type="hidden" name="market" value="{{ selected_market.name }}" />
-                        <button class="btn btn-sm btn-responsive btn-info center-block">
-                            Download metrics as CSV
-                        </button>
-                    </form>
-                    {% else %}
-                    <button class="btn btn-sm btn-responsive btn-info center-block disabled">
-                        Download metrics as CSV
-                    </button>
-                    {% endif %}
-                </div>
-                <div class="col-sm-6">
-                    {% if not current_user.has_role('anonymous') %}
-                    <form action="analytics_data/source/csv" method="get">
-                        <input type="hidden" name="market" value="{{ selected_market.name }}" />
-                        <button class="btn btn-sm btn-responsive btn-info center-block"
-                            {% if showing_individual_traces_for != 'none' %} data-toggle="tooltip"
-                            data-placement="bottom"
-                            title="This will download only aggregated data, even though you are currently seeing individual traces in the plots."
-                            {% endif%}>
-                            Download source data as CSV
-                        </button>
-                    </form>
-                    {% else %}
-                    <button class="btn btn-sm btn-responsive btn-info center-block disabled">
-                        Download source data as CSV
-                    </button>
-                    {% endif %}
-                </div>
-            </div>
-        </div>
-        <div class="col-sm-8">
-            <div class="row">
-                <div class="col-sm-12" style="height: {{ top_legend_height }}px;">
-                    {{ top_legend_div | safe }}
-                    {{ top_legend_script | safe }}
-                </div>
-            </div>
-            <div class="row">
-                <div class="col-sm-6">
-                    {{ analytics_plots_divs[0] | safe }}
-                </div>
-                <div class="col-sm-6">
-                    {{ analytics_plots_divs[1] | safe }}
-                </div>
-            </div>
-            {% if bottom_legend_height > 0 %}
-            <div class="row">
-                <div class="col-sm-12" style="height: {{ bottom_legend_height }}px;">
-                    {{ bottom_legend_div | safe }}
-                    {{ bottom_legend_script | safe }}
-                </div>
-            </div>
-            {% endif %}
-            <div class="row">
-                <div class="col-sm-6">
-                    {{ analytics_plots_divs[2] | safe }}
-                </div>
-                <div class="col-sm-6">
-                    {{ analytics_plots_divs[3] | safe }}
-                </div>
-                {{ analytics_plots_script | safe }}
-            </div>
-            <div class="row top-buffer"></div>
-        </div>
-    </div>
-</div>
-
-{% endblock %}
\ No newline at end of file
diff --git a/flexmeasures/ui/templates/views/dashboard.html b/flexmeasures/ui/templates/views/dashboard.html
deleted file mode 100644
index 2d55cfd3b..000000000
--- a/flexmeasures/ui/templates/views/dashboard.html
+++ /dev/null
@@ -1,298 +0,0 @@
-{% extends "base.html" %}
-
-{% set active_page = "dashboard" %}
-
-{% block title %} Dashboard {% endblock %}
-
-{% block divs %}
-
-<div class="container-fluid">
-    <div class="row">
-        <div class="col-sm-4 top-bottom">
-            <h3>About</h3>
-            <div class="justify">
-                <p>
-                    In a world with renewable energy, flexibility is crucial for cost and CO₂ reduction.
-                    Planning ahead allows flexible devices to profit from scheduling the best flexible
-                    actions (such as shifting or curtailing energy use).
-                </p>
-                <p>
-                    The {{ FLEXMEASURES_PLATFORM_NAME }} Platform is a tool for businesses operating electric devices.
-                    Its purpose is to realise the best value for device owners by scheduling balancing actions.
-                    If the devices draw from or supply to the power grid, {{ FLEXMEASURES_PLATFORM_NAME }} can assist in
-                    selling balancing services to energy markets.
-                    It fulfills this purpose with three services: Monitoring, forecasting and scheduling.
-                    {{ FLEXMEASURES_PLATFORM_NAME }} is designed as open-source software to empower energy service
-                    companies while they
-                    maintain autonomy over their operations and their technology roadmap.
-                </p>
-                <p>
-                    {{ FLEXMEASURES_PLATFORM_NAME }} is compliant with the Universal Smart Energy Framework (<a
-                        href="https://www.usef.energy/download-the-framework/a-flexibility-market-design/">USEF</a>), a
-                    “flexibility market design for the trading and commoditisation of energy flexibility and the
-                    architecture, tools and rules to make it work effectively.
-                    USEF fits on top of most market models and is already being adopted across Europe to accelerate and
-                    future-proof smart energy projects.”
-                </p>
-                {% if current_user.has_role('anonymous') and current_user.has_role('CPO') %}
-                <p>
-                    This demo is tuned to operators of charge points for electric vehicles (EVs).
-                    The terminology used follows the Open Charge Point Interface (<a
-                        href="https://ocpi-protocol.org/">OCPI</a>).
-                    This dashboard shows you the locations of all charge points connected to the platform and how they
-                    are doing.
-                    Each charge point contains multiple chargers&mdash;called Electric Vehicle Supply Equipment (EVSE)
-                    in OCPI&mdash;which are monitored individually (click on a charge point location to reveal its
-                    EVSE).
-                </p>
-                <p>
-                    {{ FLEXMEASURES_PLATFORM_NAME }} supports automated scheduling of charging profiles through its API.
-                    Charging profiles are optimised against applicable market conditions like wholesale prices and
-                    contracted (time-of-use) tariffs.
-                    Charging preferences can be set to reflect whether EV owners are in a hurry or parking for some
-                    time.
-                </p>
-                {% else %}
-                <p>
-                    This dashboard shows you the locations of all your devices connected to the platform and how they
-                    are doing.
-                    Assets marked red require your attention, as balancing opportunities have been identified.
-                </p>
-                {% endif %}
-                {% if documentation_exists %}
-                <p>
-                    Need help understanding this page? Check out the
-                    <a href="{{ url_for('flexmeasures_ui.static', filename='documentation/html/views/dashboard.html') }}"
-                        target="_blank">documentation</a>.
-                </p>
-                {% endif %}
-            </div>
-        </div>
-        <div class="col-sm-8">
-            <div class="row eq-height">
-                <div class="col-sm-12">
-                    <h3>
-                        {% if user_is_admin %}{{ FLEXMEASURES_PLATFORM_NAME }} ― asset status:
-                        {% else %}Status of my assets:{% endif%}
-                    </h3>
-
-                    <div id="mapid"></div>
-
-                    <table class="table" style="font-size: 100%;">
-                        <thead>
-                            <tr>
-                                <th></th>
-                                {% for asset_group in asset_groups %}
-                                {# On demo, show all non-empty groups, otherwise show all groups that are non-empty for the current user #}
-                                {% if (FLEXMEASURES_MODE == "demo" and asset_groups[asset_group].count_all > 0) or (FLEXMEASURES_MODE != "demo" and asset_groups[asset_group].count > 0)%}
-                                <th {% if asset_groups[asset_group].hover_label %}
-                                    title="{{ asset_groups[asset_group].hover_label | capitalize }}" {% endif %}
-                                    class="text-center{% if asset_group in aggregate_groups %} agg-group{% endif %}">
-<!--                                    <a href="analytics?resource={{ asset_group }}">-->
-                                        {{ asset_group | capitalize }}
-<!--                                    </a>-->
-                                </th>
-                                {% endif %}
-                                {% endfor %}
-                            </tr>
-                        </thead>
-                        <tbody>
-                            <tr>
-                                <td></td>
-
-                                {% for asset_group in asset_groups %}
-                                {% if (FLEXMEASURES_MODE == "demo" and asset_groups[asset_group].count_all > 0) or (FLEXMEASURES_MODE != "demo" and asset_groups[asset_group].count > 0)%}
-                                <td class="text-center{% if asset_group in aggregate_groups %} agg-group{% endif %}">
-                                    <div style="min-height: 20px; padding:15px;">
-                                        <i class="{{ asset_groups[asset_group].name | asset_icon }} center-icon"></i>
-                                    </div>
-                                </td>
-                                {% endif %}
-                                {% endfor %}
-                            </tr>
-                            {% if not user_is_admin %}
-                            <tr>
-                                <td>My assets:</td>
-
-                                {% for asset_group in asset_groups %}
-                                {% if (FLEXMEASURES_MODE == "demo" and asset_groups[asset_group].count_all > 0) or (FLEXMEASURES_MODE != "demo" and asset_groups[asset_group].count > 0)%}
-                                <td class="text-center{% if asset_group in aggregate_groups %} agg-group{% endif %}">
-                                    {{ asset_groups[asset_group].count }}</td>
-                                {% endif %}
-                                {% endfor %}
-                            </tr>
-                            {% endif %}
-                            {% if user_is_admin or FLEXMEASURES_MODE == "demo" %}
-                            <tr>
-                                <td>{{ FLEXMEASURES_PLATFORM_NAME }} total:</td>
-
-                                {% for asset_group in asset_groups %}
-                                {% if (FLEXMEASURES_MODE == "demo" and asset_groups[asset_group].count_all > 0) or (FLEXMEASURES_MODE != "demo" and asset_groups[asset_group].count > 0)%}
-                                <td class="text-center{% if asset_group in aggregate_groups %} agg-group{% endif %}">
-                                    {{ asset_groups[asset_group].count_all }}</td>
-                                {% endif %}
-                                {% endfor %}
-                            </tr>
-                            {% endif %}
-                        </tbody>
-                    </table>
-                </div>
-            </div>
-        </div>
-    </div>
-</div>
-
-
-<!-- Initialise the map -->
-<script src="https://cdn.jsdelivr.net/npm/leaflet@1.7.1/dist/leaflet-src.min.js"></script>
-<script src="{{ url_for('flexmeasures_ui.static', filename='js/map-init.js') }}"></script>
-<script src="https://unpkg.com/leaflet.markercluster@1.4.1/dist/leaflet.markercluster.js"></script>
-<script
-    src="https://unpkg.com/leaflet.markercluster.layersupport@2.0.1/dist/leaflet.markercluster.layersupport.js"></script>
-
-{{ bokeh_html_embedded | safe }}
-<script type="text/javascript">
-
-    function lookForState(event) {
-        $.ajax({
-            url: "/state?id=" + encodeURI(this.options.id),
-            success: stateFoundCallback
-        });
-    }
-
-    function stateFoundCallback(data) {
-        $(".bokeh-state-plot").html(data);
-    }
-
-    // create lists for markers, one per asset type, and icons, one per asset type
-
-    {% for asset_group in asset_groups %}
-    {% if asset_group not in aggregate_groups %}
-    {% if asset_groups[asset_group].count > 0 %}
-    var {{ asset_groups[asset_group].parameterized_name }}_markers = [];
-
-    var {{ asset_groups[asset_group].parameterized_name }}_icon = new L.DivIcon({
-        className: 'map-icon',
-        html: '<i class="icon-empty-marker center-icon supersize"></i><i class="overlay center-icon {{ asset_groups[asset_group].unique_asset_types[0].name | asset_icon }}"></i>',
-        iconSize: [100, 100], // size of the icon
-        iconAnchor: [50, 50], // point of the icon which will correspond to marker's location
-        popupAnchor: [0, -50] // point from which the popup should open relative to the iconAnchor
-    });
-
-    var {{ asset_groups[asset_group].parameterized_name }}_opportunity_icon = new L.DivIcon({
-        className: 'map-icon opportunity',
-        html: '<i class="{{ asset_groups[asset_group].unique_asset_types[0].name | asset_icon }}"></i>',
-        iconSize: [24, 24], // size of the icon
-        iconAnchor: [12, 12], // point of the icon which will correspond to marker's location
-        popupAnchor: [0, -12] // point from which the popup should open relative to the iconAnchor
-    });
-    {% endif %}
-    {% endif %}
-    {% endfor %}
-
-
-    // create markers, keep them in separate lists (by asset type) to be put into layers
-
-    {% for asset_group in asset_groups %}
-    {% if asset_group not in aggregate_groups %}
-    {% for asset in asset_groups[asset_group].assets %}
-                <!--var marker_for_{{ (asset.name | parameterize) }} = makeMarker({{ asset }})-->
-
-                if (typeof marker_for_{{ (asset.name | parameterize) }} == 'undefined') {
-        var marker_for_{{ (asset.name | parameterize)
-    }} = L
-        .marker(
-            [{{ asset.location[0] }}, {{ asset.location[1] }}],
-            { icon: {{ asset_groups[asset_group].parameterized_name }}_icon, id: "{{ asset.id }}"}
-    )
-        .bindPopup(`
-                            <div class="leaflet-marker-popup">
-                                <div class="row">
-                                    <div class="col-sm-12">
-                                        <h4>{{ asset.display_name }}</h4>
-                                    </div>
-                                </div>
-                                <div class="row">
-                                    <div class="col-sm-5 bokeh-state-plot"></div>
-                                    <div class="col-sm-7">
-                                        <div class="row top-buffer">
-<!--                                            <div class="col-sm-12">-->
-<!--                                                <form action="analytics" method="get">-->
-<!--                                                    <input type="hidden" name="resource" value="{{ asset.name }}">-->
-<!--                                                    <button class="btn btn-sm btn-responsive btn-info" type="submit">Analyse this asset</button>-->
-<!--                                                </form>-->
-<!--                                            </div>-->
-                                        </div>
-                                        <div class="row top-buffer">
-                                            <div class="col-sm-12">
-                                                <form action="assets/{{ asset.id }}" method="get">
-                                                    <button class="btn btn-sm btn-responsive btn-info" type="submit">Edit this asset</button>
-                                                </form>
-                                            </div>
-                                        </div>
-        <!--                                <div class="row top-buffer">-->
-        <!--                                    <div class="col-sm-12">-->
-        <!--                                        <form action="control" method="get">-->
-        <!--                                            &lt;!&ndash;<input type="hidden" name="offer" value="still_mocked">&ndash;&gt;-->
-        <!--                                            &lt;!&ndash;<button class="btn btn-sm btn-responsive btn-danger" type="submit">Review opportunity</button>&ndash;&gt;-->
-        <!--                                        </form>-->
-        <!--                                    </div>-->
-        <!--                                </div>-->
-                                    </div>
-                                </div>
-                            </div>
-                            `
-        )
-        .on('mouseover', function () {
-            $(this._icon).addClass('over');
-        })
-        .on('mouseout', function () {
-            $(this._icon).delay(3000).queue(function (next) {
-                $(this).removeClass('over');
-                next();
-            });
-        })
-        .bindTooltip("{{ asset.display_name }}",
-        {
-            permanent: false,
-            direction: 'right'
-        })
-        .on('click', clickPan)
-        .on('click', lookForState);
-    // .openPopup();
-    {{ asset_groups[asset_group].parameterized_name }}_markers.push(marker_for_{{ (asset.name | parameterize) }});
-                }
-    {% endfor %}
-    {% endif %}
-    {% endfor %}
-
-    // create Map with tiles
-    var assetMap = L
-        .map('mapid', { center: [{{ map_center[0] }}, {{ map_center[1] }}], zoom: 6 })
-        .on('popupopen', function () {
-            $(function () {
-                $('[data-toggle="tooltip"]').tooltip();
-            });
-        });
-    addTileLayer(assetMap, '{{ mapboxAccessToken }}');
-    var mcgLayerSupportGroup = L.markerClusterGroup.layerSupport();
-    var control = L.control.layers(null, null, { collapsed: true });
-
-    // add a layer for each asset type
-    {% for asset_group in asset_groups %}
-    {% if asset_group not in aggregate_groups %}
-    {% if asset_groups[asset_group].count > 0 %}
-    var {{ asset_groups[asset_group].parameterized_name }}_layer = new L.LayerGroup({{ asset_groups[asset_group].parameterized_name }}_markers);
-    mcgLayerSupportGroup.checkIn({{ asset_groups[asset_group].parameterized_name }}_layer);
-    control.addOverlay({{ asset_groups[asset_group].parameterized_name }}_layer, "{{ asset_group | capitalize }}")
-    {{ asset_groups[asset_group].parameterized_name }}_layer.addTo(assetMap);
-    {% endif %}
-    {% endif %}
-    {% endfor %}
-    mcgLayerSupportGroup.addTo(assetMap);
-    control.addTo(assetMap);
-
-</script>
-
-
-{% endblock %}
\ No newline at end of file
diff --git a/flexmeasures/ui/templates/views/new_dashboard.html b/flexmeasures/ui/templates/views/new_dashboard.html
index 40d89634e..da45744a5 100644
--- a/flexmeasures/ui/templates/views/new_dashboard.html
+++ b/flexmeasures/ui/templates/views/new_dashboard.html
@@ -84,20 +84,8 @@
 <script
     src="https://unpkg.com/leaflet.markercluster.layersupport@2.0.1/dist/leaflet.markercluster.layersupport.js"></script>
 
-{{ bokeh_html_embedded | safe }}
 <script type="text/javascript">
 
-    function lookForState(event) {
-        $.ajax({
-            url: "/asset/" + encodeURI(this.options.id) + "/state",
-            success: stateFoundCallback
-        });
-    }
-
-    function stateFoundCallback(data) {
-        $(".bokeh-state-plot").html(data);
-    }
-
     // Make an icon for each known asset type
     {% for asset_type_name in known_asset_types %}
 
@@ -138,37 +126,17 @@
         .bindPopup(`
                             <div class="leaflet-marker-popup">
                                 <div class="row">
-                                    <div class="col-sm-12">
+                                    <div class="col-sm-9">
                                         <h4>{{ asset.name }} </h4>
                                         {% if user_has_admin_reader_rights %} <small>Account: {{ asset.owner.name }} </small> {% endif %}
                                     </div>
                                 </div>
-                                <div class="row">
-                                    <div class="col-sm-5 bokeh-state-plot"></div>
-                                    <div class="col-sm-7">
-                                        <div class="row top-buffer">
-<!--                                            <div class="col-sm-12">-->
-<!--                                                <form action="analytics" method="get">-->
-<!--                                                    <input type="hidden" name="resource" value="{{ asset.name }}">-->
-<!--                                                    <button class="btn btn-sm btn-responsive btn-info" type="submit">Analyse this asset</button>-->
-<!--                                                </form>-->
-<!--                                            </div>-->
-                                        </div>
-                                        <div class="row top-buffer">
-                                            <div class="col-sm-12">
-                                                <form action="assets/{{ asset.id }}" method="get">
-                                                    <button class="btn btn-sm btn-responsive btn-info" type="submit">View this asset</button>
-                                                </form>
-                                            </div>
-                                        </div>
-        <!--                                <div class="row top-buffer">-->
-        <!--                                    <div class="col-sm-12">-->
-        <!--                                        <form action="control" method="get">-->
-        <!--                                            &lt;!&ndash;<input type="hidden" name="offer" value="still_mocked">&ndash;&gt;-->
-        <!--                                            &lt;!&ndash;<button class="btn btn-sm btn-responsive btn-danger" type="submit">Review opportunity</button>&ndash;&gt;-->
-        <!--                                        </form>-->
-        <!--                                    </div>-->
-        <!--                                </div>-->
+                                <div class="row top-buffer">
+                                    <div class="col-sm-6"></div>
+                                    <div class="col-sm-6">
+                                        <form action="assets/{{ asset.id }}" method="get">
+                                            <button class="btn btn-sm btn-responsive btn-info" type="submit">View</button>
+                                        </form>
                                     </div>
                                 </div>
                             </div>
@@ -189,7 +157,6 @@ <h4>{{ asset.name }} </h4>
             direction: 'right'
         })
         .on('click', clickPan)
-        .on('click', lookForState);
     // .openPopup();
     {{ asset_groups[asset_group_name].parameterized_name }}_markers.push(marker_for_{{ (asset.id) }});
                 }
diff --git a/flexmeasures/ui/templates/views/portfolio.html b/flexmeasures/ui/templates/views/portfolio.html
deleted file mode 100644
index 2cd7c7886..000000000
--- a/flexmeasures/ui/templates/views/portfolio.html
+++ /dev/null
@@ -1,281 +0,0 @@
-{% extends "base.html" %}
-
-{% set active_page = "portfolio" %}
-
-{% block title %} Portfolio overview {% endblock %}
-
-{% block divs %}
-
-<div class="container-fluid">
-    <div class="row">
-        <div class="col-sm-4 top-bottom">
-            <h3>About</h3>
-            <div class="justify">
-                <p>
-                    This page shows you how your portfolio of assets is doing in terms of energy (production &amp;
-                    consumption) and flexibility.
-                    Highlighted time slots require your attention, as upcoming balancing opportunities have been
-                    identified.
-                </p>
-                {% if documentation_exists %}
-                <p>
-                    Need help understanding this page? Check out the
-                    <a href="{{ url_for('flexmeasures_ui.static', filename='documentation/html/views/portfolio.html') }}"
-                        target="_blank">documentation</a>.
-                </p>
-                {% endif %}
-            </div>
-
-            <h3>Controls</h3>
-            {% block datetimepicker %} {{ super() }} {% endblock %}
-
-            {% if asset_types | length > 0 %}
-            <h3>Energy summary</h3>
-            <table class="table text-right" style="font-size: 100%;">
-                <thead>
-                    <tr>
-                        <th rowspan="2"><i class="icon-calculator icon-center"></i></th>
-                        <th class="text-right"><span title="All production by assets of this type.">Production</span>
-                        </th>
-                        <th class="text-right"><span title="All consumption by assets of this type.">Consumption</span>
-                        </th>
-                        <!--th><span title="The revenue achieved by assets of this type (on the day-ahead market).">Revenue</span></th-->
-                    </tr>
-                    <tr>
-                        <th class="text-right">MWh</th>
-                        <th class="text-right">MWh</th>
-                        <!--<th class="text-right">KRW</th>-->
-                    </tr>
-                </thead>
-                <tbody>
-                    {% for asset_type_name in asset_types %}
-                    <tr>
-                        <td class="text-left" {% if asset_types[asset_type_name].hover_label %}
-                            title="{{ asset_types[asset_type_name].hover_label | capitalize }}" {% endif %}>
-                            {{ asset_type_name | capitalize }}
-                        </td>
-                        <td>{% if asset_types[asset_type_name].is_producer and production_per_asset_type[asset_type_name] > 0  %}
-                            {{ "{:,.3f}".format( production_per_asset_type[asset_type_name] ) }}
-                            {% endif %}
-                        </td>
-                        <td>{% if asset_types[asset_type_name].is_consumer and consumption_per_asset_type[asset_type_name] > 0 %}
-                            {{ "{:,.3f}".format( consumption_per_asset_type[asset_type_name] ) }}
-                            {% endif %}
-                        </td>
-                    </tr>
-                    {% endfor %}
-                    {% if asset_types | length > 1 %}
-                    <tr style="font-weight: bold;">
-                        <td class="text-left">
-                            Total
-                        </td>
-                        <td>{{ "{:,.3f}".format( sum_production ) }}</td>
-                        <td>{{ "{:,.3f}".format( sum_consumption ) }}</td>
-                    </tr>
-                    {% endif %}
-                </tbody>
-
-            </table>
-
-            {% if fm_mode == "demo" %}
-            <h3>Flexibility summary</h3>
-            <table class="table text-right" style="font-size: 100%;">
-                <thead>
-                    <tr>
-                        <th rowspan="2"><i class="icon-calculator icon-center"></i></th>
-                        <th class="text-right"><span
-                                title="All curtailment actions by assets of this type.">Curtailment</span></th>
-                        <th class="text-right"><span
-                                title="All shifting actions by assets of this type.">Shifting</span></th>
-                        <th class="text-right"><span
-                                title="The revenue achieved by assets of this type (by payment of supplier).">Revenue</span>
-                        </th>
-                    </tr>
-                    <tr>
-                        <th class="text-right">MWh</th>
-                        <th class="text-right">MWh * h</th>
-                        <th class="text-right">KRW</th>
-                    </tr>
-                </thead>
-                <tbody>
-                    {% for asset_type_name in asset_types %}
-                    <tr>
-                        <td class="text-right" {% if asset_types[asset_type_name].hover_label %}
-                            title="{{ asset_types[asset_type_name].hover_label | capitalize }}" {% endif %}>
-                            {{ asset_type_name | capitalize }}
-                        </td>
-                        <td>{% if asset_types[asset_type_name].is_producer and flex_info["curtailment_per_asset_type"][asset_type_name] > 0  %}
-                            {{ "{:,.3f}".format( flex_info["curtailment_per_asset_type"][asset_type_name] ) }}
-                            {% endif %}
-                        </td>
-                        <td>{% if asset_types[asset_type_name].is_consumer and flex_info["shifting_per_asset_type"][asset_type_name] > 0  %}
-                            {{ "{:,.3f}".format( flex_info["shifting_per_asset_type"][asset_type_name] ) }}
-                            {% endif %}
-                        </td>
-                        <td>{{ "{:,.2f}".format( flex_info["profit_loss_flexibility_per_asset_type"][asset_type_name] ) }}</td>
-                    </tr>
-                    {% endfor %}
-                    {% if asset_types | length > 1 %}
-                    <tr style="font-weight: bold;">
-                        <td class="text-left">
-                            Total
-                        </td>
-                        <td>{{ "{:,.3f}".format( flex_info["sum_curtailment"] ) }}</td>
-                        <td>{{ "{:,.3f}".format( flex_info["sum_shifting"] ) }}</td>
-                        <td>{{ "{:,.2f}".format( flex_info["sum_profit_loss_flexibility"] ) }}</td>
-                    </tr>
-                    {% endif %}
-                </tbody>
-            </table>
-            {% endif %}
-            {% endif %}
-            
-            <h3>Market status</h3>
-            <table
-                class="table table-striped table-responsive paginate-5 {% if markets | length > 5 %}paginate{% else %}paginate-without-paging{% endif %}"
-                style="font-size: 100%;">
-                <thead>
-                    <tr>
-                        <th>Market</th>
-                        <th>Currency</th>
-                        <th>Average price</th>
-                    </tr>
-                </thead>
-                <tbody>
-                    {% for market in markets: %}
-                    <tr>
-                        <td>{{ market.display_name }}</td>
-                        <td>{{ market.unit[:3] }}</td>
-                        <td>
-                            {% if not average_prices[market.name] | isnull %}
-                            {{ "{:,.2f}".format( average_prices[market.name] ) }} {{ market.unit }}
-                            {% else %}
-                            Unknown for the requested period
-                            {% endif %}
-                        </td>
-                    </tr>
-                    {% endfor %}
-                </tbody>
-            </table>
-
-        </div>
-        <div class="col-sm-8">
-            <div class="row">
-                <div class="col-sm-12">
-                    <h3>Portfolio status</h3>
-                    <div class="alert alert-info">
-                        {% if fm_mode == "demo" %}
-                        <p>For onshore and vehicles, an opportunity is mocked for the following 4am timeslot (select
-                            time window "today" or "tomorrow").
-                            <b>Double-click the opportunity</b> to see the order book. </p>
-                        {% endif%}
-                        <p> You can also
-                            <button onclick="window.location.href='?show_stacked={{alt_stacking}}'">
-                                Switch to showing stacked {{ alt_stacking }}
-                            </button>
-                        </p>
-                    </div>
-                    {% if fm_mode == "demo" %}
-                    <div class="row">
-                        <div class="col-sm-12">
-                            {{ portfolio_plots_divs[0] | safe }}
-                        </div>
-                    </div>
-                    <div class="row">
-                        <div class="col-sm-12">
-                            {{ portfolio_plots_divs[1] | safe }}
-                        </div>
-                    </div>
-                    {% else %}
-                    <div class="row">
-                        <div class="col-sm-12">
-                            {{ portfolio_plots_divs | safe }}
-                        </div>
-                    </div>
-                    {% endif %}
-                    {{ portfolio_plots_script | safe }}
-                </div>
-            </div>
-
-            <div class="row">
-                <div class="col-sm-12">
-                    <h3>Asset status</h3>
-                </div>
-            </div>
-            <div class="row">
-                <div class="col-sm-12">
-                    <table class="table table-striped table-responsive paginate paginate-10">
-                        <thead>
-                            <tr>
-                                <th>Asset</th>
-                                <!--<th>Energy Service Company</th>-->
-                                <th class="text-right">Production</th>
-                                <th class="text-right">Consumption</th>
-                                {% if fm_mode == "demo" %}
-                                <th class="text-right">Curtailment</th>
-                                <th class="text-right">Shifting</th>
-                                <th class="text-right">Balancing revenue</th>
-                                {% endif %}
-                                <th class="no-sort"></th>
-                            </tr>
-                        </thead>
-
-                        <tbody>
-                            {% for asset in assets: %}
-                            <tr>
-                                <td>
-                                    <i class="{{ asset.asset_type_name | asset_icon }} left-icon"><a
-                                            href="/assets/{{ asset.id }}"
-                                            title="Show asset meta data and location on map">{{ asset.display_name }}</a></i>
-                                </td>
-                                <td class="text-right">
-                                    {% if production_per_asset[asset.name] > 0 %}
-                                    {{ "{:,.2f}".format( production_per_asset[asset.name] ) }} MWh
-                                    {% endif %}
-                                </td>
-                                <td class="text-right">
-                                    {% if consumption_per_asset[asset.name] < 0 %}
-                                    {{ "{:,.2f}".format( consumption_per_asset[asset.name] ) }} MWh
-                                    {% endif %}
-                                </td>
-                                {% if fm_mode == "demo" %}
-                                <td class="text-right">
-                                    {% if asset.asset_type.can_curtail and flex_info["curtailment_per_asset"][asset.name] > 0 %}
-                                    {{ "{:,.2f}".format( flex_info["curtailment_per_asset"][asset.name] ) }} MWh
-                                    {% endif %}
-                                </td>
-                                <td class="text-right">
-                                    {% if asset.asset_type.can_shift and flex_info["shifting_per_asset"][asset.name] > 0 %}
-                                    {{ "{:,.2f}".format( flex_info["shifting_per_asset"][asset.name] ) }} MWh
-                                    {% endif %}
-                                </td>
-                                <td class="text-right">
-                                    {{ "{:,.2f}".format( flex_info["profit_loss_flexibility_per_asset"][asset.name] ) }} KRW
-                                </td>
-                                {% endif %}
-                                <td class="text-right">
-                                    <form action="analytics" method="get">
-                                        <input type="hidden" name="resource" value="{{ asset.name }}">
-                                        <button class="btn btn-sm btn-responsive btn-info" type="submit">Analyse this
-                                            asset</button>
-                                    </form>
-                                </td>
-                            </tr>
-                            {% endfor %}
-                        </tbody>
-                    </table>
-                </div>
-            </div>
-
-            <div class="row">
-                <div class="col-sm-12">
-
-                </div>
-            </div>
-        </div>
-    </div>
-</div>
-
-{% block paginate_tables_script %} {{ super() }} {% endblock %}
-
-{% endblock %}
\ No newline at end of file
diff --git a/flexmeasures/ui/utils/plotting_utils.py b/flexmeasures/ui/utils/plotting_utils.py
deleted file mode 100644
index d75c3dcad..000000000
--- a/flexmeasures/ui/utils/plotting_utils.py
+++ /dev/null
@@ -1,659 +0,0 @@
-from typing import Any, List, Optional, Tuple, Union
-from datetime import datetime, timedelta
-
-from flask import current_app
-from colour import Color
-from bokeh.plotting import figure, Figure
-from bokeh.models import (
-    Plot,
-    ColumnDataSource,
-    HoverTool,
-    NumeralTickFormatter,
-    BoxAnnotation,
-    CustomJS,
-    Legend,
-    Range1d,
-)
-from bokeh.models.renderers import GlyphRenderer
-from bokeh.models.tools import CustomJSHover
-from bokeh.palettes import brewer as brewer_palette
-from bokeh import events
-import pandas as pd
-import pandas_bokeh
-import numpy as np
-import timely_beliefs as tb
-
-from flexmeasures.data.models.assets import Asset
-from flexmeasures.data.models.data_sources import DataSource
-from flexmeasures.data.services.time_series import convert_query_window_for_demo
-from flexmeasures.utils.flexmeasures_inflection import capitalize
-from flexmeasures.utils.time_utils import (
-    server_now,
-    localized_datetime_str,
-    tz_index_naively,
-)
-from flexmeasures.ui.utils.view_utils import set_time_range_for_session
-
-"""
-This module is by now considered legacy, as we're moving away from Bokeh and to Altair.
-See flexmeasures/data/models/charts
-"""
-
-
-def create_hover_tool(  # noqa: C901
-    y_unit: str, resolution: timedelta, as_beliefs: bool = False
-) -> HoverTool:
-    """Describe behaviour of default tooltips
-    (we could also return html for custom tooltips)
-
-    Uses from_py_func, a deprecated function since bokeh==1.1
-    https://docs.bokeh.org/en/latest/docs/releases.html?highlight=from_py_func
-    """
-
-    def horizon_formatter() -> str:
-        horizon = value  # type:ignore  # noqa
-
-        def ngettext(message, plural, num):
-            if num == 1:
-                return message
-            else:
-                return plural
-
-        def naturaltime(delta):
-            """humanize.naturaldelta adjusted for use in pscript"""
-
-            use_months = False
-            seconds = abs(delta // 1000)
-            days = seconds // (60 * 60 * 24)
-            years = days // 365
-            days = days % 365
-            months = int(days // 30.5)
-
-            if not years and days < 1:
-                if seconds == 0:
-                    return "a moment"
-                elif seconds == 1:
-                    return "a second"
-                elif seconds < 60:
-                    return ngettext(
-                        "%d second" % seconds, "%d seconds" % seconds, seconds
-                    )
-                elif 60 <= seconds < 120:
-                    return "a minute"
-                elif 120 <= seconds < 3600:
-                    minutes = seconds // 60
-                    return ngettext(
-                        "%d minute" % minutes, "%d minutes" % minutes, minutes
-                    )
-                elif 3600 <= seconds < 3600 * 2:
-                    return "an hour"
-                elif 3600 < seconds:
-                    hours = seconds // 3600
-                    return ngettext("%d hour" % hours, "%d hours" % hours, hours)
-            elif years == 0:
-                if days == 1:
-                    return "a day"
-                if not use_months:
-                    return ngettext("%d day" % days, "%d days" % days, days)
-                else:
-                    if not months:
-                        return ngettext("%d day" % days, "%d days" % days, days)
-                    elif months == 1:
-                        return "a month"
-                    else:
-                        return ngettext(
-                            "%d month" % months, "%d months" % months, months
-                        )
-            elif years == 1:
-                if not months and not days:
-                    return "a year"
-                elif not months:
-                    return ngettext(
-                        "1 year, %d day" % days, "1 year, %d days" % days, days
-                    )
-                elif use_months:
-                    if months == 1:
-                        return "1 year, 1 month"
-                    else:
-                        return ngettext(
-                            "1 year, %d month" % months,
-                            "1 year, %d months" % months,
-                            months,
-                        )
-                else:
-                    return ngettext(
-                        "1 year, %d day" % days, "1 year, %d days" % days, days
-                    )
-            else:
-                return ngettext("%d year" % years, "%d years" % years, years)
-
-        if isinstance(horizon, list):
-            if len(horizon) == 1:
-                horizon = horizon[0]
-            else:
-                min_horizon = min(horizon)
-                if min_horizon < 0:
-                    return "at most %s after realisation" % naturaltime(min_horizon)
-                elif min_horizon > 0:
-                    return "at least %s before realisation" % naturaltime(min_horizon)
-                else:
-                    return "exactly at realisation"
-        if horizon < 0:
-            return "%s after realisation" % naturaltime(horizon)
-        elif horizon > 0:
-            return "%s before realisation" % naturaltime(horizon)
-        else:
-            return "exactly at realisation"
-
-    custom_horizon_string = CustomJSHover.from_py_func(horizon_formatter)
-    if resolution.seconds == 0:
-        date_format = "@x{%F} to @next_x{%F}"
-    else:
-        date_format = "@x{%F %H:%M} to @next_x{%F %H:%M}"
-
-    tooltips = [("Time", date_format), ("Value", "@y{0.000a} %s" % y_unit)]
-    if as_beliefs:
-        tooltips.append(("Description", "@label @belief_horizon{custom}."))
-    return HoverTool(
-        tooltips=tooltips,
-        formatters={
-            "x": "datetime",
-            "next_x": "datetime",
-            "y": "numeral",
-            "belief_horizon": custom_horizon_string,
-        },
-    )
-
-
-def make_range(
-    index: pd.DatetimeIndex, other_index: pd.DatetimeIndex = None
-) -> Optional[Range1d]:
-    """Make a 1D range of values from a datetime index or two. Useful to share axis among Bokeh Figures."""
-    index = tz_index_naively(index)
-    other_index = tz_index_naively(other_index)
-    a_range = None
-    # if there is some actual data, use that to set the range
-    if not index.empty:
-        a_range = Range1d(start=min(index), end=max(index))
-    # if there is other data, include it
-    if not index.empty and other_index is not None and not other_index.empty:
-        a_range = Range1d(
-            start=min(index.append(other_index)), end=max(index.append(other_index))
-        )
-    if a_range is None:
-        current_app.logger.warning("Not sufficient data to create a range.")
-    return a_range
-
-
-def replace_source_with_label(data: pd.DataFrame) -> pd.DataFrame:
-    """
-    Column "source" is dropped, and column "label" is created.
-    The former column should contain DataSource objects,
-    while the latter will contain only strings.
-    """
-    if data is not None:
-        # source is in the multindex when we trace sources individually
-        if "source" not in data.columns and "source" in data.index.names:
-            data.reset_index(level="source", inplace=True)
-        if "source" in data.columns:
-            data["label"] = data["source"].apply(
-                lambda x: capitalize(x.label)
-                if isinstance(x, DataSource)
-                else str(x).capitalize()
-            )
-            data.drop("source", axis=1, inplace=True)
-    return data
-
-
-def decide_plot_resolution(
-    data: pd.DataFrame,
-) -> timedelta:
-    """
-    Finding out which resolution to use:
-    prefer resolution in data, otherwise from session (which is based on the session's time period)
-    """
-    if isinstance(data, tb.BeliefsDataFrame):
-        resolution = data.event_resolution
-    elif not data.empty and data.index.freq is not None:
-        resolution = pd.to_timedelta(data.index.freq)
-    else:
-        from flask import session
-
-        if "resolution" not in session:
-            set_time_range_for_session()
-        resolution = pd.to_timedelta(session["resolution"])
-    return resolution
-
-
-def build_palette() -> Tuple[List[str], str, str]:
-    """Choose a color palette, and also single out
-    our primary, forecasting and scheduling colors"""
-    palette = (
-        brewer_palette["Set1"][8].copy() * 15
-    )  # 7 colors tiled 15 times supports 105 legend items
-    primary_color = "#3B0757"
-    palette.insert(0, primary_color)
-    palette.pop(4)  # too similar to primary
-    forecast_color = "#DDD0B3"
-    schedule_color = palette.pop(2)
-    return palette, forecast_color, schedule_color
-
-
-def create_graph(  # noqa: C901
-    data: pd.DataFrame,
-    unit: str = "Some unit",
-    title: str = "A plot",
-    x_label: str = "X",
-    y_label: str = "Y",
-    legend_location: Union[str, Tuple[float, float]] = "top_right",
-    legend_labels: Tuple[str, Optional[str], Optional[str]] = (
-        "Actual",
-        "Forecast",
-        "Schedules",
-    ),
-    x_range: Optional[Range1d] = None,
-    forecasts: Optional[pd.DataFrame] = None,
-    schedules: Optional[pd.DataFrame] = None,
-    show_y_floats: bool = False,
-    non_negative_only: bool = False,
-    tools: Optional[List[str]] = None,
-    sizing_mode: str = "scale_width",
-) -> Figure:
-    """
-    Create a Bokeh graph. As of now, assumes x data is datetimes and y data is numeric. The former is not set in stone.
-
-    :param data: the actual data. Expects column name "event_value" and optional "belief_horizon" and "source" columns.
-    :param unit: the (physical) unit of the data
-    :param title: Title of the graph
-    :param x_label: x axis label
-    :param y_label: y axis label
-    :param legend_location: location of the legend
-    :param legend_labels: labels for the legend items
-    :param x_range: values for x axis. If None, taken from series index.
-    :param forecasts: forecasts of the data. Expects column names "event_value", "yhat_upper" and "yhat_lower".
-    :param schedules: scheduled data. Expects column name "event_value".
-    :param hover_tool: Bokeh hover tool, if required
-    :param show_y_floats: if True, y axis will show floating numbers (defaults False, will be True if y values are < 2)
-    :param non_negative_only: whether or not the data can only be non-negative
-    :param tools: some tools for the plot, which defaults to ["box_zoom", "reset", "save"].
-    :return: a Bokeh Figure
-    """
-
-    # Replace "source" column with "label" column (containing strings)
-    data = replace_source_with_label(data)
-    forecasts = replace_source_with_label(forecasts)
-    schedules = replace_source_with_label(schedules)
-    resolution = decide_plot_resolution(data)
-
-    # Set x range
-    if x_range is None:
-        x_range = make_range(data.index)
-        if x_range is None and schedules is not None:
-            x_range = make_range(schedules.index)
-        if x_range is None and forecasts is not None:
-            x_range = make_range(forecasts.index)
-    data = tz_index_naively(data)
-
-    # Set default y range in case there is no data from which to derive a range
-    y_range = None
-    if (
-        data["event_value"].isnull().all()
-        and (forecasts is None or forecasts["event_value"].isnull().all())
-        and (schedules is None or schedules["event_value"].isnull().all())
-    ):
-        y_range = Range1d(start=0, end=1)
-
-    # Set default tools if none were given
-    if tools is None:
-        tools = ["box_zoom", "reset", "save"]
-    if "belief_horizon" in data.columns and "label" in data.columns:
-        hover_tool = create_hover_tool(unit, resolution, as_beliefs=True)
-    else:
-        hover_tool = create_hover_tool(unit, resolution, as_beliefs=False)
-    tools = [hover_tool] + tools
-
-    fig = figure(
-        title=title,
-        x_range=x_range,
-        y_range=y_range,
-        min_border=0,
-        toolbar_location="right",
-        tools=tools,
-        h_symmetry=False,
-        v_symmetry=False,
-        sizing_mode=sizing_mode,
-        outline_line_color="#666666",
-    )
-
-    if non_negative_only:
-        fig.y_range.bounds = (0, None)
-        fig.y_range.start = 0
-
-    if data.empty:
-        current_app.logger.warning("No data to show for %s" % title)
-
-    # Format y floats
-    if (
-        not data.empty and show_y_floats is False and data["event_value"].size > 0
-    ):  # apply a simple heuristic
-        if forecasts is None or forecasts.empty:
-            show_y_floats = max(data["event_value"].values) < 2
-        else:
-            show_y_floats = (
-                max(max(data["event_value"].values), max(forecasts["event_value"])) < 2
-            )
-
-    palette, forecast_color, schedule_color = build_palette()
-    legend_items: List[Tuple] = []
-
-    # Plot power data. Support special case of multiple source labels.
-    if not data.empty:
-        data_groups = {legend_labels[0]: data}
-        is_multiple = "label" in data.columns and len(data["label"].unique()) > 1
-        if is_multiple:
-            data_groups = {
-                label: data.loc[data.label == label] for label in data["label"].unique()
-            }
-        legend_items = []
-        for plot_label, plot_data in data_groups.items():
-            ds = make_datasource_from(plot_data, resolution)
-            if not is_multiple:
-                ac = fig.circle(
-                    x="x", y="y", source=ds, color=palette.pop(0), alpha=0.5, size=10
-                )
-            else:
-                ac = fig.line(x="x", y="y", source=ds, color=palette.pop(0))
-            legend_items.append((plot_label, [ac]))
-
-    # Plot forecast data
-    if forecasts is not None and not forecasts.empty:
-        forecasts = tz_index_naively(forecasts)
-        if "label" not in forecasts:
-            forecasts["label"] = "Forecast from unknown source"
-        labels = forecasts["label"].unique()
-        for label in labels:
-            # forecasts from different data sources
-            label_forecasts = forecasts[forecasts["label"] == label]
-            fds = make_datasource_from(label_forecasts, resolution)
-            fc = fig.circle(x="x", y="y", source=fds, color=forecast_color, size=10)
-            fl = fig.line(x="x", y="y", source=fds, color=forecast_color)
-
-            # draw uncertainty range as a two-dimensional patch
-            if "yhat_lower" and "yhat_upper" in label_forecasts:
-                x_points = np.append(label_forecasts.index, label_forecasts.index[::-1])
-                y_points = np.append(
-                    label_forecasts.yhat_lower, label_forecasts.yhat_upper[::-1]
-                )
-                fig.patch(
-                    x_points,
-                    y_points,
-                    color=forecast_color,
-                    fill_alpha=0.2,
-                    line_width=0.01,
-                )
-            if legend_labels[1] is None:
-                raise TypeError("Legend label must be of type string, not None.")
-            if label == labels[0]:
-                # only add 1 legend item for forecasts
-                legend_items.append((legend_labels[1], [fc, fl]))
-
-    # Plot schedule data. Support special case of multiple source labels.
-    if (
-        schedules is not None
-        and not schedules.empty
-        and not schedules["event_value"].isnull().all()
-    ):
-        schedules = tz_index_naively(schedules)
-
-        legend_label = "" if legend_labels[2] is None else legend_labels[2]
-        schedule_groups = {legend_label: schedules}
-        if "label" in schedules.columns and len(schedules["label"].unique()) > 1:
-            schedule_groups = {
-                label: schedules.loc[schedules.label == label]
-                for label in schedules["label"].unique()
-            }
-        for plot_label, plot_data in schedule_groups.items():
-            sds = make_datasource_from(plot_data, resolution)
-            sl = fig.line(x="x", y="y", source=sds, color=palette.pop(0))
-
-            if plot_label is None:
-                raise TypeError("Legend label must be of type string, not None.")
-            legend_items.append((plot_label, [sl]))
-
-    fig.toolbar.logo = None
-    fig.yaxis.axis_label = y_label
-    fig.yaxis.formatter = NumeralTickFormatter(format="0,0")
-    if show_y_floats:
-        fig.yaxis.formatter = NumeralTickFormatter(format="0,0.00")
-    fig.ygrid.grid_line_alpha = 0.5
-    fig.xaxis.axis_label = x_label
-    fig.xgrid.grid_line_alpha = 0.5
-
-    if legend_location is not None:
-        legend = Legend(items=legend_items, location=legend_location)
-        fig.add_layout(legend, "center")
-
-    return fig
-
-
-def make_datasource_from(data: pd.DataFrame, resolution: timedelta) -> ColumnDataSource:
-    """Make a bokeh data source, which is for instance useful for the hover tool."""
-
-    # Set column names that our HoverTool can interpret (in case of multiple index levels, use the first one)
-    data.index.names = ["x"] + data.index.names[1:]
-    data = data.rename(columns={"event_value": "y"})
-
-    # If we have a DatetimeIndex, we encode with each x (start time) also the boundary to which it runs (end time).
-    # TODO: can be extended to work with other types
-    if (
-        data.index.values.size
-        and isinstance(data.index, pd.DatetimeIndex)
-        and resolution is not None
-    ):  # i.e. if there is a non-empty DatetimeIndex and a resolution is given
-        data["next_x"] = data.index.shift(1, freq=resolution)
-    return ColumnDataSource(data)
-
-
-def highlight(
-    fig: Figure,
-    x_start: Any,
-    x_end: Any,
-    color: str = "#FF3936",
-    redirect_to: str = None,
-):
-    """Add a box highlight to an area above the x axis.
-    If a redirection URL is given, it can open the URL on double-click (this assumes datetimes are used on x axis!).
-    It will pass the year, month, day, hour and minute as parameters to the URL."""
-    ba = BoxAnnotation(
-        left=x_start, right=x_end, fill_alpha=0.1, line_color=color, fill_color=color
-    )
-    fig.add_layout(ba)
-
-    if redirect_to is not None:
-        if isinstance(x_start, datetime):
-
-            def open_order_book(
-                o_url: str, box_start: datetime, box_end: datetime
-            ) -> CustomJS:
-                return CustomJS(
-                    code="""
-                    var boxStartDate = new Date("%s");
-                    var boxEndDate = new Date("%s");
-                    var clickedDate = new Date(cb_obj["x"]);
-                    // This quick-fixes some localisation behaviour in bokeh JS (a bug?). Bring back to UTC.
-                    clickedDate = new Date(clickedDate.getTime() + clickedDate.getTimezoneOffset() * 60000);
-                    console.log("tapped!!");
-                    if (boxStartDate <= clickedDate && clickedDate <= boxEndDate) {
-                        // TODO: change this to a URL which fits the order book once we actually make it work
-                        var urlPlusParams = "%s" + "?year=" + clickedDate.getUTCFullYear()
-                                                 + "&month=" + (clickedDate.getUTCMonth()+1)
-                                                 + "&day=" + clickedDate.getUTCDate()
-                                                 + "&hour=" + clickedDate.getUTCHours()
-                                                 + "&minute=" + clickedDate.getMinutes();
-                        $(location).attr("href", urlPlusParams);
-                    }
-                """
-                    % (
-                        box_start.replace(tzinfo=None),
-                        box_end.replace(tzinfo=None),
-                        o_url,
-                    )
-                )
-
-        else:
-            raise NotImplementedError(
-                "Highlighting only works for datetime ranges"
-            )  # TODO: implement for other x-range types
-        fig.js_on_event(events.DoubleTap, open_order_book(redirect_to, x_start, x_end))
-
-
-def compute_legend_height(legend) -> float:
-    """Adapted from:
-    https://github.com/bokeh/bokeh/blob/master/bokehjs/src/lib/models/annotations/legend.ts
-    """
-    if legend.orientation == "vertical":
-        return (
-            max(legend.label_height, legend.glyph_height) * len(legend.items)
-            + legend.spacing * max(len(legend.items) - 1, 0)
-            + legend.padding * 2
-        )
-    else:
-        return max(legend.label_height, legend.glyph_height) + legend.padding * 2
-
-
-def separate_legend(fig: Figure, orientation: str = "vertical") -> Figure:
-    """Cuts legend out of fig and returns a separate legend (as a Figure object).
-    Click policy doesn't work on the new legend.
-    Requires bokeh==1.0.2 for solution from https://groups.google.com/a/continuum.io/forum/#!topic/bokeh/BJRhWlnmhWU
-    Open feature request to share a legend across plots: https://github.com/bokeh/bokeh/issues/7607
-    """
-
-    legend_fig = Plot(
-        x_range=Range1d(1000, 1000),
-        y_range=Range1d(1000, 1000),
-        min_border=0,
-        outline_line_alpha=0,
-        toolbar_location=None,
-        sizing_mode="stretch_both",  # if stretch_both, then we need to set the height or min-height of the container
-    )
-
-    original_legend = fig.legend[0]
-
-    legend_fig.renderers.append(original_legend)
-    legend_fig.renderers.extend(
-        [renderer for renderer in fig.renderers if isinstance(renderer, GlyphRenderer)]
-    )
-
-    fig.renderers.remove(original_legend)
-    separated_legend = legend_fig.legend[0]
-    separated_legend.border_line_alpha = 0
-    separated_legend.margin = 0
-
-    if len(separated_legend.items) > 3:
-        orientation = "vertical"
-
-    separated_legend.orientation = orientation
-
-    if orientation == "horizontal":
-        separated_legend.spacing = 30
-    separated_legend.location = "top_center"
-
-    legend_fig.plot_height = (
-        compute_legend_height(original_legend) + original_legend.margin * 2
-    )
-
-    return legend_fig
-
-
-def get_latest_power_as_plot(asset: Asset, small: bool = False) -> Tuple[str, str]:
-    """Create a plot of an asset's latest power measurement as an embeddable html string (incl. javascript).
-    First returned string is the measurement time, second string is the html string."""
-
-    if current_app.config.get("FLEXMEASURES_MODE", "") == "play":
-        before = None  # type:ignore
-    else:
-        before = server_now()
-        _, before = convert_query_window_for_demo((before, before))
-
-    latest_power = asset.latest_state(event_ends_before=before)
-    if latest_power is not None:
-        latest_power_value = latest_power.value
-        if current_app.config.get("FLEXMEASURES_MODE", "") == "demo":
-            latest_power_datetime = latest_power.datetime.replace(
-                year=datetime.now().year
-            )
-        else:
-            latest_power_datetime = latest_power.datetime
-        latest_measurement_time_str = localized_datetime_str(
-            latest_power_datetime + asset.event_resolution
-        )
-    else:
-        latest_power_value = 0
-        latest_measurement_time_str = "time unknown"
-    if latest_power_value < 0:
-        consumption = True
-        latest_power_value *= -1
-    else:
-        consumption = False
-
-    data = {
-        latest_measurement_time_str if not small else "": [0],
-        "Capacity in use": [latest_power_value],
-        "Remaining capacity": [asset.capacity_in_mw - latest_power_value],
-    }
-    percentage_capacity = latest_power_value / asset.capacity_in_mw
-    df = pd.DataFrame(data)
-    p = df.plot_bokeh(
-        kind="bar",
-        x=latest_measurement_time_str if not small else "",
-        y=["Capacity in use", "Remaining capacity"],
-        stacked=True,
-        colormap=[
-            "%s"
-            % Color(
-                hue=0.3 * min(1.0, 3 / 2 * percentage_capacity),
-                saturation=1,
-                luminance=min(0.5, 1 - percentage_capacity * 3 / 4),
-            ).get_hex_l(),  # 0% red, 38% yellow, 67% green, >67% darker green
-            "#f7ebe7",
-        ],
-        alpha=0.7,
-        title=None,
-        xlabel=None,
-        ylabel="Power (%s)" % asset.unit,
-        zooming=False,
-        show_figure=False,
-        hovertool=None,
-        legend=None,
-        toolbar_location=None,
-        figsize=(200, 400) if not small else (100, 100),
-        ylim=(0, asset.capacity_in_mw),
-        xlim=(-0.5, 0.5),
-    )
-    p.xgrid.visible = False
-    for r in p.renderers:
-        try:
-            r.glyph.width = 1
-        except AttributeError:
-            pass
-    p.xaxis.ticker = []
-    p.add_layout(
-        BoxAnnotation(bottom=0, top=asset.capacity_in_mw, fill_color="#f7ebe7")
-    )
-    plot_html_str = pandas_bokeh.embedded_html(p)
-    hover_tool_str = "%s at %s %s (%s%% capacity).\nLatest state at %s." % (
-        "Consuming"
-        if consumption
-        else "Running"
-        if latest_power_value == 0
-        else "Producing",
-        round(latest_power_value, 3),
-        asset.unit,
-        round(100 * percentage_capacity),
-        latest_measurement_time_str,
-    )
-    return (
-        latest_measurement_time_str,
-        """<div data-toggle="tooltip" data-placement="bottom" title="%s">%s</div>"""
-        % (hover_tool_str, plot_html_str),
-    )
diff --git a/flexmeasures/ui/utils/view_utils.py b/flexmeasures/ui/utils/view_utils.py
index 6004370e2..9baddf4ee 100644
--- a/flexmeasures/ui/utils/view_utils.py
+++ b/flexmeasures/ui/utils/view_utils.py
@@ -6,7 +6,6 @@
 from datetime import datetime
 
 from flask import render_template, request, session, current_app
-from bokeh.resources import CDN
 from flask_security.core import current_user
 from werkzeug.exceptions import BadRequest
 import iso8601
@@ -54,12 +53,6 @@ def render_flexmeasures_template(html_filename: str, **variables):
     if "show_datepicker" not in variables:
         variables["show_datepicker"] = variables["page"] in ("analytics", "portfolio")
 
-    variables["contains_plots"] = False
-    if any([n.endswith(("plots_div", "plots_divs")) for n in variables.keys()]):
-        variables["contains_plots"] = True
-        variables["bokeh_css_resources"] = CDN.render_css()
-        variables["bokeh_js_resources"] = CDN.render_js()
-
     variables["resolution"] = session.get("resolution", "")
     variables["resolution_human"] = time_utils.freq_label_to_human_readable_label(
         session.get("resolution", "")
diff --git a/flexmeasures/ui/views/__init__.py b/flexmeasures/ui/views/__init__.py
index a1980d413..763fabc52 100644
--- a/flexmeasures/ui/views/__init__.py
+++ b/flexmeasures/ui/views/__init__.py
@@ -3,21 +3,13 @@
 from flexmeasures.ui import flexmeasures_ui
 
 # Now views can register
-from flexmeasures.ui.views.dashboard import (  # noqa: F401
-    dashboard_view as legacy_dashboard_view,
-)
-from flexmeasures.ui.views.new_dashboard import new_dashboard_view  # noqa: F401
-from flexmeasures.ui.views.portfolio import portfolio_view  # noqa: F401
+from flexmeasures.ui.views.new_dashboard import dashboard_view  # noqa: F401
 from flexmeasures.ui.views.control import control_view  # noqa: F401
-from flexmeasures.ui.views.analytics import analytics_view  # noqa: F401
-from flexmeasures.ui.views.state import sensor_state_view  # noqa: F401
 
 from flexmeasures.ui.views.logged_in_user import (  # noqa: F401  # noqa: F401
     logged_in_user_view,
 )
 
-from flexmeasures.ui.views.charts import get_power_chart  # noqa: F401
-
 
 @flexmeasures_ui.route("/docs")
 def docs_view():
diff --git a/flexmeasures/ui/views/analytics.py b/flexmeasures/ui/views/analytics.py
deleted file mode 100644
index ffbfa42a8..000000000
--- a/flexmeasures/ui/views/analytics.py
+++ /dev/null
@@ -1,724 +0,0 @@
-from typing import List, Optional, Union, Tuple, Dict
-from datetime import datetime, timedelta
-import io
-import csv
-import json
-import math
-
-import pandas as pd
-from flask import session, current_app, make_response
-from bokeh.plotting import Figure
-from bokeh.embed import components
-from bokeh.util.string import encode_utf8
-from bokeh.models import Range1d
-from pandas.tseries.frequencies import to_offset
-
-from flexmeasures.auth.decorators import account_roles_accepted
-from flexmeasures.data.models.markets import Market
-from flexmeasures.data.models.time_series import Sensor
-from flexmeasures.data.services.resources import (
-    get_assets,
-    get_asset_group_queries,
-    get_markets,
-    get_sensor_types,
-    Resource,
-)
-from flexmeasures.data.queries.analytics import (
-    get_power_data,
-    get_prices_data,
-    get_weather_data,
-    get_revenues_costs_data,
-)
-from flexmeasures.utils import time_utils
-from flexmeasures.ui.utils.view_utils import (
-    render_flexmeasures_template,
-    set_session_resource,
-    set_session_market,
-    set_session_sensor_type,
-    set_individual_traces_for_session,
-    set_time_range_for_session,
-    ensure_timing_vars_are_set,
-)
-from flexmeasures.ui.utils.plotting_utils import create_graph, separate_legend
-from flexmeasures.ui.views import flexmeasures_ui
-
-"""
-These views are considered legacy by now. They is too specific to a use case and also
-rely on Bokeh. We might move them (re-implement with Altair) in a plugin.
-When removing this, also remove the templates.
-"""
-
-
-@flexmeasures_ui.route("/analytics", methods=["GET", "POST"])
-@account_roles_accepted("Prosumer")
-def analytics_view():
-    """Analytics view. Here, four plots (consumption/generation, weather, prices and a profit/loss calculation)
-    and a table of metrics data are prepared. This view allows to select a resource name, from which a
-    `models.Resource` object can be made. The resource name is kept in the session.
-    Based on the resource, plots and table are labelled appropriately.
-    """
-    set_time_range_for_session()
-    markets = get_markets()
-    assets = get_assets(order_by_asset_attribute="display_name", order_direction="asc")
-    asset_groups = get_asset_group_queries(
-        custom_additional_groups=["renewables", "EVSE", "location"]
-    )
-    asset_group_names: List[str] = [
-        group for group in asset_groups if asset_groups[group].count() > 0
-    ]
-    selected_resource = set_session_resource(assets, asset_group_names)
-    if selected_resource is None:
-        raise Exception(
-            "No assets exist yet, so the analytics view will not work. Please add an asset!"
-        )
-
-    selected_market = set_session_market(selected_resource)
-    sensor_types = get_sensor_types(selected_resource)
-    session_asset_types = selected_resource.unique_asset_types
-    selected_sensor_type = set_session_sensor_type(sensor_types)
-    set_individual_traces_for_session()
-    view_shows_individual_traces = (
-        session["showing_individual_traces_for"] in ("power", "schedules")
-        and selected_resource.is_eligible_for_comparing_individual_traces()
-    )
-
-    query_window, resolution = ensure_timing_vars_are_set((None, None), None)
-
-    # This is useful information - we might want to adapt the sign of the data and labels.
-    showing_pure_consumption_data = all(
-        [a.is_pure_consumer for a in selected_resource.assets]
-    )
-    showing_pure_production_data = all(
-        [a.is_pure_producer for a in selected_resource.assets]
-    )
-    # Only show production positive if all assets are producers
-    show_consumption_as_positive = False if showing_pure_production_data else True
-
-    # ---- Get data
-
-    data, metrics, weather_type, selected_weather_sensor = get_data_and_metrics(
-        query_window,
-        resolution,
-        show_consumption_as_positive,
-        session["showing_individual_traces_for"]
-        if view_shows_individual_traces
-        else "none",
-        selected_resource,
-        selected_market.corresponding_sensor,
-        selected_sensor_type,
-        selected_resource.assets,
-    )
-
-    # TODO: get rid of these hacks, which we use because we mock the current year's data from 2015 data in demo mode
-    # Our demo server uses 2015 data as if it's the current year's data. Here we mask future beliefs.
-    if current_app.config.get("FLEXMEASURES_MODE", "") == "demo":
-        data = filter_for_past_data(data)
-        data = filter_forecasts_for_limited_time_window(data)
-
-    # ---- Making figures
-
-    # Set shared x range
-    shared_x_range = Range1d(start=query_window[0], end=query_window[1])
-    shared_x_range2 = Range1d(
-        start=query_window[0], end=query_window[1]
-    )  # only needed if we draw two legends (if individual traces are on)
-
-    tools = ["box_zoom", "reset", "save"]
-    power_fig = make_power_figure(
-        selected_resource.display_name,
-        data["power"],
-        data["power_forecast"],
-        data["power_schedule"],
-        show_consumption_as_positive,
-        shared_x_range,
-        tools=tools,
-    )
-    rev_cost_fig = make_revenues_costs_figure(
-        selected_resource.display_name,
-        data["rev_cost"],
-        data["rev_cost_forecast"],
-        show_consumption_as_positive,
-        shared_x_range,
-        selected_market,
-        tools=tools,
-    )
-    # the bottom plots need a separate x axis if they get their own legend (Bokeh complains otherwise)
-    # this means in that in that corner case zooming will not work across all four plots
-    prices_fig = make_prices_figure(
-        data["prices"],
-        data["prices_forecast"],
-        shared_x_range2 if view_shows_individual_traces else shared_x_range,
-        selected_market,
-        tools=tools,
-    )
-    weather_fig = make_weather_figure(
-        selected_resource,
-        data["weather"],
-        data["weather_forecast"],
-        shared_x_range2 if view_shows_individual_traces else shared_x_range,
-        selected_weather_sensor,
-        tools=tools,
-    )
-
-    # Separate a single legend and remove the others.
-    # In case of individual traces, we need two legends.
-    top_legend_fig = separate_legend(power_fig, orientation="horizontal")
-    top_legend_script, top_legend_div = components(top_legend_fig)
-    rev_cost_fig.renderers.remove(rev_cost_fig.legend[0])
-    if view_shows_individual_traces:
-        bottom_legend_fig = separate_legend(weather_fig, orientation="horizontal")
-        prices_fig.renderers.remove(prices_fig.legend[0])
-        bottom_legend_script, bottom_legend_div = components(bottom_legend_fig)
-    else:
-        prices_fig.renderers.remove(prices_fig.legend[0])
-        weather_fig.renderers.remove(weather_fig.legend[0])
-        bottom_legend_fig = bottom_legend_script = bottom_legend_div = None
-
-    analytics_plots_script, analytics_plots_divs = components(
-        (power_fig, rev_cost_fig, prices_fig, weather_fig)
-    )
-
-    return render_flexmeasures_template(
-        "views/analytics.html",
-        top_legend_height=top_legend_fig.plot_height,
-        top_legend_script=top_legend_script,
-        top_legend_div=top_legend_div,
-        bottom_legend_height=0
-        if bottom_legend_fig is None
-        else bottom_legend_fig.plot_height,
-        bottom_legend_script=bottom_legend_script,
-        bottom_legend_div=bottom_legend_div,
-        analytics_plots_divs=[encode_utf8(div) for div in analytics_plots_divs],
-        analytics_plots_script=analytics_plots_script,
-        metrics=metrics,
-        markets=markets,
-        sensor_types=sensor_types,
-        assets=assets,
-        asset_group_names=asset_group_names,
-        selected_market=selected_market,
-        selected_resource=selected_resource,
-        selected_sensor_type=selected_sensor_type,
-        selected_sensor=selected_weather_sensor,
-        asset_types=session_asset_types,
-        showing_pure_consumption_data=showing_pure_consumption_data,
-        showing_pure_production_data=showing_pure_production_data,
-        show_consumption_as_positive=show_consumption_as_positive,
-        showing_individual_traces_for=session["showing_individual_traces_for"],
-        offer_showing_individual_traces=selected_resource.is_eligible_for_comparing_individual_traces(),
-        forecast_horizons=time_utils.forecast_horizons_for(session["resolution"]),
-        active_forecast_horizon=session["forecast_horizon"],
-    )
-
-
-@flexmeasures_ui.route("/analytics_data/<content>/<content_type>", methods=["GET"])
-@account_roles_accepted("Prosumer")
-def analytics_data_view(content, content_type):
-    """Analytics view as above, but here we only download data.
-    Content can be either source or metrics.
-    Content-type can be either CSV or JSON.
-    """
-    # if current_app.config.get("FLEXMEASURES_MODE", "") != "play":
-    #    raise NotImplementedError("The analytics data download only works in play mode.")
-    if content not in ("source", "metrics"):
-        if content is None:
-            content = "data"
-        else:
-            raise NotImplementedError("content can either be source or metrics.")
-    if content_type not in ("csv", "json"):
-        if content_type is None:
-            content_type = "csv"
-        else:
-            raise NotImplementedError("content_type can either be csv or json.")
-
-    time_utils.set_time_range_for_session()
-
-    # Maybe move some of this stuff into get_data_and_metrics
-    assets = get_assets(order_by_asset_attribute="display_name", order_direction="asc")
-    asset_groups = get_asset_group_queries(
-        custom_additional_groups=["renewables", "EVSE", "location"]
-    )
-    asset_group_names: List[str] = [
-        group for group in asset_groups if asset_groups[group].count() > 0
-    ]
-    selected_resource = set_session_resource(assets, asset_group_names)
-    selected_market = set_session_market(selected_resource)
-    sensor_types = get_sensor_types(selected_resource)
-    selected_sensor_type = set_session_sensor_type(sensor_types)
-
-    # This is useful information - we might want to adapt the sign of the data and labels.
-    showing_pure_consumption_data = all(
-        [a.is_pure_consumer for a in selected_resource.assets]
-    )
-    showing_pure_production_data = all(
-        [a.is_pure_producer for a in selected_resource.assets]
-    )
-    # Only show production positive if all assets are producers
-    show_consumption_as_positive = False if showing_pure_production_data else True
-
-    # Getting data and calculating metrics for them
-    query_window, resolution = ensure_timing_vars_are_set((None, None), None)
-    data, metrics, weather_type, selected_weather_sensor = get_data_and_metrics(
-        query_window,
-        resolution,
-        show_consumption_as_positive,
-        "none",
-        selected_resource,
-        selected_market.corresponding_sensor,
-        selected_sensor_type,
-        selected_resource.assets,
-    )
-
-    horizon = session["forecast_horizon"]
-    rev_cost_header = (
-        "costs/revenues" if show_consumption_as_positive else "revenues/costs"
-    )
-    if showing_pure_consumption_data:
-        rev_cost_header = "costs"
-    elif showing_pure_production_data:
-        rev_cost_header = "revenues"
-    source_headers = [
-        "time",
-        "power_data_label",
-        "power",
-        "power_forecast_label",
-        f"power_forecast_{horizon}",
-        f"{weather_type}_label",
-        f"{weather_type}",
-        f"{weather_type}_forecast_label",
-        f"{weather_type}_forecast_{horizon}",
-        "price_label",
-        f"price_on_{selected_market.name}",
-        "price_forecast_label",
-        f"price_forecast_{horizon}",
-        f"{rev_cost_header}_label",
-        rev_cost_header,
-        f"{rev_cost_header}_forecast_label",
-        f"{rev_cost_header}_forecast_{horizon}",
-    ]
-    source_units = [
-        "",
-        "",
-        "MW",
-        "",
-        "MW",
-        "",
-        selected_weather_sensor.unit,
-        "",
-        selected_weather_sensor.unit,
-        "",
-        selected_market.price_unit,
-        "",
-        selected_market.price_unit,
-        "",
-        selected_market.price_unit[:3],
-        "",
-        selected_market.price_unit[:3],
-    ]
-    filename_prefix = "%s_analytics" % selected_resource.name
-    if content_type == "csv":
-        str_io = io.StringIO()
-        writer = csv.writer(str_io, dialect="excel")
-        if content == "metrics":
-            filename = "%s_metrics.csv" % filename_prefix
-            writer.writerow(metrics.keys())
-            writer.writerow(metrics.values())
-        else:
-            filename = "%s_source.csv" % filename_prefix
-            writer.writerow(source_headers)
-            writer.writerow(source_units)
-            for dt in data["rev_cost"].index:
-                row = [
-                    dt,
-                    data["power"].loc[dt].label
-                    if "label" in data["power"].columns
-                    else "Aggregated power data",
-                    data["power"].loc[dt]["event_value"],
-                    data["power_forecast"].loc[dt].label
-                    if "label" in data["power_forecast"].columns
-                    else "Aggregated power forecast data",
-                    data["power_forecast"].loc[dt]["event_value"],
-                    data["weather"].loc[dt].label
-                    if "label" in data["weather"].columns
-                    else f"Aggregated {weather_type} data",
-                    data["weather"].loc[dt]["event_value"],
-                    data["weather_forecast"].loc[dt].label
-                    if "label" in data["weather_forecast"].columns
-                    else f"Aggregated {weather_type} forecast data",
-                    data["weather_forecast"].loc[dt]["event_value"],
-                    data["prices"].loc[dt].label
-                    if "label" in data["prices"].columns
-                    else "Aggregated power data",
-                    data["prices"].loc[dt]["event_value"],
-                    data["prices_forecast"].loc[dt].label
-                    if "label" in data["prices_forecast"].columns
-                    else "Aggregated power data",
-                    data["prices_forecast"].loc[dt]["event_value"],
-                    data["rev_cost"].loc[dt].label
-                    if "label" in data["rev_cost"].columns
-                    else f"Aggregated {rev_cost_header} data",
-                    data["rev_cost"].loc[dt]["event_value"],
-                    data["rev_cost_forecast"].loc[dt].label
-                    if "label" in data["rev_cost_forecast"].columns
-                    else f"Aggregated {rev_cost_header} forecast data",
-                    data["rev_cost_forecast"].loc[dt]["event_value"],
-                ]
-                writer.writerow(row)
-
-        response = make_response(str_io.getvalue())
-        response.headers["Content-Disposition"] = "attachment; filename=%s" % filename
-        response.headers["Content-type"] = "text/csv"
-    else:
-        if content == "metrics":
-            filename = "%s_metrics.json" % filename_prefix
-            response = make_response(json.dumps(metrics))
-        else:
-            # Not quite done yet. I don't like how we treat forecasts in here yet. Not sure how to mention units.
-            filename = "%s_source.json" % filename_prefix
-            json_strings = []
-            for key in data:
-                json_strings.append(
-                    f"\"{key}\":{data[key].to_json(orient='index', date_format='iso')}"
-                )
-            response = make_response("{%s}" % ",".join(json_strings))
-        response.headers["Content-Disposition"] = "attachment; filename=%s" % filename
-        response.headers["Content-type"] = "application/json"
-    return response
-
-
-def get_data_and_metrics(
-    query_window: Tuple[datetime, datetime],
-    resolution: str,
-    show_consumption_as_positive: bool,
-    showing_individual_traces_for: str,
-    selected_resource: Resource,
-    selected_market_sensor: Sensor,
-    selected_sensor_type,
-    assets,
-) -> Tuple[Dict[str, pd.DataFrame], Dict[str, float], str, Sensor]:
-    """Getting data and calculating metrics for them"""
-    data: Dict[str, pd.DataFrame] = dict()
-    forecast_horizon = pd.to_timedelta(session["forecast_horizon"])
-    metrics: dict = dict()
-    (
-        data["power"],
-        data["power_forecast"],
-        data["power_schedule"],
-        metrics,
-    ) = get_power_data(
-        selected_resource,
-        show_consumption_as_positive,
-        showing_individual_traces_for,
-        metrics,
-        query_window,
-        resolution,
-        forecast_horizon,
-    )
-    data["prices"], data["prices_forecast"], metrics = get_prices_data(
-        metrics,
-        selected_market_sensor,
-        query_window,
-        resolution,
-        forecast_horizon,
-    )
-    (
-        data["weather"],
-        data["weather_forecast"],
-        weather_type,
-        selected_sensor,
-        metrics,
-    ) = get_weather_data(
-        assets,
-        metrics,
-        selected_sensor_type,
-        query_window,
-        resolution,
-        forecast_horizon,
-    )
-    # TODO: get rid of this hack, which we use because we mock forecast intervals in demo mode
-    if current_app.config.get("FLEXMEASURES_MODE", "") == "demo":
-        # In each case below, the error increases with the horizon towards a certain percentage of the point forecast
-        horizon = pd.to_timedelta(to_offset(session.get("forecast_horizon", "1d")))
-        decay_factor = 1 - math.exp(-horizon / timedelta(hours=6))
-
-        # Heuristic power confidence interval
-        error_margin = 0.1 * decay_factor
-        data["power_forecast"]["yhat_upper"] = data["power_forecast"]["event_value"] * (
-            1 + error_margin
-        )
-        data["power_forecast"]["yhat_lower"] = data["power_forecast"]["event_value"] * (
-            1 - error_margin
-        )
-
-        # Heuristic price confidence interval
-        error_margin_upper = 0.6 * decay_factor
-        error_margin_lower = 0.3 * decay_factor
-        data["prices_forecast"]["yhat_upper"] = data["prices_forecast"][
-            "event_value"
-        ] * (1 + error_margin_upper)
-        data["prices_forecast"]["yhat_lower"] = data["prices_forecast"][
-            "event_value"
-        ] * (1 - error_margin_lower)
-
-        # Heuristic weather confidence interval
-        if weather_type == "temperature":
-            error_margin_upper = 0.7 * decay_factor
-            error_margin_lower = error_margin_upper
-        elif weather_type == "wind speed":
-            error_margin_upper = 1.5 * decay_factor
-            error_margin_lower = 0.8 * decay_factor
-        elif weather_type == "irradiance":
-            error_margin_upper = 1.8 * decay_factor
-            error_margin_lower = 0.5 * decay_factor
-        if data["weather_forecast"].empty:
-            data["weather_forecast"] = data["weather"].copy()
-            data["weather_forecast"]["event_value"] *= 1.1
-        data["weather_forecast"]["yhat_upper"] = data["weather_forecast"][
-            "event_value"
-        ] * (1 + error_margin_upper)
-        data["weather_forecast"]["yhat_lower"] = data["weather_forecast"][
-            "event_value"
-        ] * (1 - error_margin_lower)
-
-    unit_factor = revenue_unit_factor("MWh", selected_market_sensor.unit)
-    data["rev_cost"], data["rev_cost_forecast"], metrics = get_revenues_costs_data(
-        data["power"],
-        data["prices"],
-        data["power_forecast"],
-        data["prices_forecast"],
-        metrics,
-        unit_factor,
-        resolution,
-        showing_individual_traces_for in ("power", "schedules"),
-    )
-    return data, metrics, weather_type, selected_sensor
-
-
-def filter_for_past_data(data):
-    """Make sure we only show past data, useful for demo mode"""
-    most_recent_quarter = time_utils.get_most_recent_quarter()
-
-    if not data["power"].empty:
-        data["power"] = data["power"].loc[
-            data["power"].index.get_level_values("event_start") < most_recent_quarter
-        ]
-    if not data["prices"].empty:
-        data["prices"] = data["prices"].loc[
-            data["prices"].index < most_recent_quarter + timedelta(hours=24)
-        ]  # keep tomorrow's prices
-    if not data["weather"].empty:
-        data["weather"] = data["weather"].loc[
-            data["weather"].index < most_recent_quarter
-        ]
-    if not data["rev_cost"].empty:
-        data["rev_cost"] = data["rev_cost"].loc[
-            data["rev_cost"].index.get_level_values("event_start") < most_recent_quarter
-        ]
-    return data
-
-
-def filter_forecasts_for_limited_time_window(data):
-    """Show forecasts only up to a limited horizon"""
-    most_recent_quarter = time_utils.get_most_recent_quarter()
-    horizon_days = 10  # keep a 10 day forecast
-    max_forecast_datetime = most_recent_quarter + timedelta(hours=horizon_days * 24)
-    if not data["power_forecast"].empty:
-        data["power_forecast"] = data["power_forecast"].loc[
-            data["power_forecast"].index < max_forecast_datetime
-        ]
-    if not data["prices_forecast"].empty:
-        data["prices_forecast"] = data["prices_forecast"].loc[
-            data["prices_forecast"].index < max_forecast_datetime
-        ]
-    if not data["weather_forecast"].empty:
-        data["weather_forecast"] = data["weather_forecast"].loc[
-            data["weather_forecast"].index < max_forecast_datetime
-        ]
-    if not data["rev_cost_forecast"].empty:
-        data["rev_cost_forecast"] = data["rev_cost_forecast"].loc[
-            data["rev_cost_forecast"].index < max_forecast_datetime
-        ]
-    return data
-
-
-def make_power_figure(
-    resource_display_name: str,
-    data: pd.DataFrame,
-    forecast_data: Optional[pd.DataFrame],
-    schedule_data: Optional[pd.DataFrame],
-    show_consumption_as_positive: bool,
-    shared_x_range: Range1d,
-    tools: List[str] = None,
-    sizing_mode="scale_width",
-) -> Figure:
-    """Make a bokeh figure for power consumption or generation"""
-    if show_consumption_as_positive:
-        title = "Electricity consumption of %s" % resource_display_name
-    else:
-        title = "Electricity production from %s" % resource_display_name
-    if data.empty:
-        title = title.replace("Electricity", "Prognosed")
-
-    return create_graph(
-        data,
-        unit="MW",
-        legend_location="top_right",
-        legend_labels=("Actual", "Forecast", None)
-        if schedule_data is None or schedule_data["event_value"].isnull().all()
-        else ("Actual", "Forecast", "Schedule"),
-        forecasts=forecast_data,
-        schedules=schedule_data,
-        title=title,
-        x_range=shared_x_range,
-        x_label="Time (resolution of %s)"
-        % determine_resolution(data, forecast_data, schedule_data),
-        y_label="Power (in MW)",
-        show_y_floats=True,
-        tools=tools,
-        sizing_mode=sizing_mode,
-    )
-
-
-def make_prices_figure(
-    data: pd.DataFrame,
-    forecast_data: Union[None, pd.DataFrame],
-    shared_x_range: Range1d,
-    selected_market: Market,
-    tools: List[str] = None,
-    sizing_mode="scale_width",
-) -> Figure:
-    """Make a bokeh figure for price data"""
-    return create_graph(
-        data,
-        unit=selected_market.unit,
-        legend_location="top_right",
-        forecasts=forecast_data,
-        title=f"Prices for {selected_market.display_name}",
-        x_range=shared_x_range,
-        x_label="Time (resolution of %s)" % determine_resolution(data, forecast_data),
-        y_label="Price (in %s)" % selected_market.unit,
-        show_y_floats=True,
-        tools=tools,
-        sizing_mode=sizing_mode,
-    )
-
-
-def make_weather_figure(
-    selected_resource: Resource,
-    data: pd.DataFrame,
-    forecast_data: Union[None, pd.DataFrame],
-    shared_x_range: Range1d,
-    weather_sensor: Sensor,
-    tools: List[str] = None,
-    sizing_mode="scale_width",
-) -> Figure:
-    """Make a bokeh figure for weather data"""
-    # Todo: plot average temperature/irradiance/wind speed for asset groups, and update title accordingly
-    if weather_sensor is None:
-        return create_graph(
-            pd.DataFrame(columns=["event_value"]),
-            title="Weather plot (no relevant weather sensor found)",
-        )
-    unit = weather_sensor.unit
-    weather_axis_label = "%s (in %s)" % (
-        weather_sensor.generic_asset.generic_asset_type.description,
-        unit,
-    )
-
-    if selected_resource.is_unique_asset:
-        title = "%s at %s" % (
-            weather_sensor.generic_asset.generic_asset_type.description,
-            selected_resource.display_name,
-        )
-    else:
-        title = "%s" % weather_sensor.generic_asset.generic_asset_type.description
-    return create_graph(
-        data,
-        unit=unit,
-        forecasts=forecast_data,
-        title=title,
-        x_range=shared_x_range,
-        x_label="Time (resolution of %s)" % determine_resolution(data, forecast_data),
-        y_label=weather_axis_label,
-        legend_location="top_right",
-        show_y_floats=True,
-        tools=tools,
-        sizing_mode=sizing_mode,
-    )
-
-
-def make_revenues_costs_figure(
-    resource_display_name: str,
-    data: pd.DataFrame,
-    forecast_data: pd.DataFrame,
-    show_consumption_as_positive: bool,
-    shared_x_range: Range1d,
-    selected_market: Market,
-    tools: List[str] = None,
-    sizing_mode="scale_width",
-) -> Figure:
-    """Make a bokeh figure for revenues / costs data"""
-    if show_consumption_as_positive:
-        rev_cost_str = "Costs"
-    else:
-        rev_cost_str = "Revenues"
-
-    return create_graph(
-        data,
-        unit=selected_market.unit[
-            :3
-        ],  # First three letters of a price unit give the currency (ISO 4217)
-        legend_location="top_right",
-        forecasts=forecast_data,
-        title=f"{rev_cost_str} for {resource_display_name} (on {selected_market.display_name})",
-        x_range=shared_x_range,
-        x_label="Time (resolution of %s)" % determine_resolution(data, forecast_data),
-        y_label="%s (in %s)" % (rev_cost_str, selected_market.unit[:3]),
-        show_y_floats=True,
-        tools=tools,
-        sizing_mode=sizing_mode,
-    )
-
-
-def revenue_unit_factor(quantity_unit: str, price_unit: str) -> float:
-    market_quantity_unit = price_unit[
-        4:
-    ]  # First three letters of a price unit give the currency (ISO 4217), fourth character is "/"
-    if quantity_unit == market_quantity_unit:
-        return 1
-    elif quantity_unit == "MWh" and price_unit[4:] == "kWh":
-        return 1000
-    else:
-        raise NotImplementedError
-
-
-def determine_resolution(
-    data: pd.DataFrame,
-    forecasts: Optional[pd.DataFrame] = None,
-    schedules: Optional[pd.DataFrame] = None,
-) -> str:
-    """
-    Determine the resolution to be displayed under the plot.
-    We try to get it from the DataFrame's meta data, or guess from the actual data.
-    Lastly, we try the session.
-    If nothing can be found this way, the resulting string is "?"
-    """
-
-    def determine_resolution_for(df: pd.DataFrame) -> str:
-        if hasattr(df, "event_resolution"):  # BeliefsDataFrame
-            freq_str = time_utils.timedelta_to_pandas_freq_str(df.event_resolution)
-        elif hasattr(df.index, "freqstr") and df.index.freqstr is not None:
-            freq_str = data.index.freqstr
-        elif hasattr(df.index, "inferred_freq") and df.index.inferred_freq is not None:
-            freq_str = df.index.inferred_freq
-        elif "resolution" in session:
-            freq_str = session["resolution"]
-        else:
-            return "?"
-        return time_utils.freq_label_to_human_readable_label(freq_str)
-
-    resolution = determine_resolution_for(data)
-    if resolution == "?" and forecasts is not None:
-        resolution = determine_resolution_for(forecasts)
-    if resolution == "?" and schedules is not None:
-        resolution = determine_resolution_for(schedules)
-    return resolution
diff --git a/flexmeasures/ui/views/charts.py b/flexmeasures/ui/views/charts.py
deleted file mode 100644
index 02b669a30..000000000
--- a/flexmeasures/ui/views/charts.py
+++ /dev/null
@@ -1,130 +0,0 @@
-from flask_json import as_json
-from bokeh.embed import json_item
-from marshmallow import Schema, fields, validate
-from webargs.flaskparser import use_args
-
-from flexmeasures.auth.decorators import account_roles_accepted
-from flexmeasures.api.v2_0 import flexmeasures_api as flexmeasures_api_v2_0
-from flexmeasures.api.v2_0.routes import v2_0_service_listing
-from flexmeasures.data.schemas.times import DurationField
-from flexmeasures.data.queries.analytics import get_power_data
-from flexmeasures.ui.views.analytics import make_power_figure
-
-
-"""
-An endpoint to get a power chart.
-
-This chart view is considered legacy now. See flexmeasures/data/models/charts for our new approach
-to support charts per API, using Altair and the Sensor ID.
-It also uses the old data model.
-"""
-
-
-v2_0_service_listing["services"].append(
-    {
-        "name": "GET /charts/power",
-        "access": ["admin", "Prosumer"],
-        "description": "Get a Bokeh chart for power data to embed in web pages.",
-    },
-)
-
-
-class ChartRequestSchema(Schema):
-    """
-    This schema describes the request for a chart.
-    """
-
-    resource = fields.Str(required=True)
-    start_time = fields.DateTime(required=True)
-    end_time = fields.DateTime(required=True)
-    resolution = DurationField(required=True)
-    show_consumption_as_positive = fields.Bool(missing=True)
-    show_individual_traces_for = fields.Str(
-        missing="none", validate=validate.OneOf(["none", "schedules", "power"])
-    )
-    forecast_horizon = DurationField(missing="PT6H")
-
-
-@flexmeasures_api_v2_0.route("/charts/power", methods=["GET"])
-@account_roles_accepted("Prosumer")
-@use_args(ChartRequestSchema(), location="querystring")
-@as_json
-def get_power_chart(chart_request):
-    """API endpoint to get a chart for power data which can be embedded in web pages.
-
-    .. :quickref: Chart; Get a power chart
-
-    This endpoint returns a Bokeh chart with power data which can be embedded in a website.
-    It includes forecasts and even schedules, if available.
-
-    **Example request**
-
-    An example of a chart request:
-
-    .. sourcecode:: json
-
-        {
-            "resource": ""my-battery,
-            "start_time": "2020-02-20:10:00:00UTC",
-            "end_time": "2020-02-20:11:00:00UTC",
-            "resolution": "PT15M",
-            "consumption_as_positive": true
-            "resolution": "PT6H",
-            "show_individual_traces_for": "none"  // can be power or schedules
-        }
-
-    On your webpage, you need to include the Bokeh libraries, e.g.:
-
-        <script src="https://cdn.pydata.org/bokeh/release/bokeh-1.0.4.min.js"></script>
-
-    (The version needs to match the version used by the FlexMeasures server, see requirements/app.txt)
-
-    Then you can call this endpoint and include the result like this:
-
-    .. sourcecode:: javascript
-
-        <script>
-            fetch('http://localhost:5000/api/v2_0/charts/power?' + urlData.toString(),
-            {
-                method: "GET",
-                mode: "cors",
-                headers:
-                    {
-                        "Content-Type": "application/json",
-                        "Authorization": "<users auth token>"
-                    },
-            })
-            .then(function(response) { return response.json(); })
-            .then(function(item) { Bokeh.embed.embed_item(item, "<ID of the div >"); });
-        </script>
-
-    where `urlData` is a `URLSearchData` object and contains the chart request parameters (see above).
-
-    :reqheader Authorization: The authentication token
-    :reqheader Content-Type: application/json
-    :resheader Content-Type: application/json
-    :status 200: PROCESSED
-    :status 400: INVALID_REQUEST
-    :status 401: UNAUTHORIZED
-    :status 403: INVALID_SENDER
-    :status 422: UNPROCESSABLE_ENTITY
-    """
-    data = get_power_data(
-        resource=chart_request["resource"],
-        show_consumption_as_positive=chart_request["show_consumption_as_positive"],
-        showing_individual_traces_for=chart_request["show_individual_traces_for"],
-        metrics={},  # will be stored here, we don't need them for now
-        query_window=(chart_request["start_time"], chart_request["end_time"]),
-        resolution=chart_request["resolution"],
-        forecast_horizon=chart_request["forecast_horizon"],
-    )
-    figure = make_power_figure(
-        resource_display_name=chart_request["resource"],
-        data=data[0],
-        forecast_data=data[1],
-        schedule_data=data[2],
-        show_consumption_as_positive=chart_request["show_consumption_as_positive"],
-        shared_x_range=None,
-        sizing_mode="scale_both",
-    )
-    return json_item(figure)
diff --git a/flexmeasures/ui/views/dashboard.py b/flexmeasures/ui/views/dashboard.py
deleted file mode 100644
index 17d52be35..000000000
--- a/flexmeasures/ui/views/dashboard.py
+++ /dev/null
@@ -1,58 +0,0 @@
-from bokeh.resources import CDN
-from flask import request, current_app
-from flask_security import login_required
-from flask_security.core import current_user
-
-from flexmeasures.ui.views import flexmeasures_ui
-from flexmeasures.ui.utils.view_utils import render_flexmeasures_template, clear_session
-from flexmeasures.data.services.resources import (
-    get_asset_group_queries,
-    Resource,
-    get_center_location,
-)
-
-"""
-Note: This view is deprecated / legacy.
-Also remove its HTML template.
-"""
-
-
-@flexmeasures_ui.route("/legacy-dashboard")
-@login_required
-def dashboard_view():
-    """Dashboard view.
-    This is the default landing page for the platform user.
-    It shows a map with the location and status of all of the user's assets,
-    as well as a breakdown of the asset types in the user's portfolio.
-    Assets for which the platform has identified upcoming balancing opportunities are highlighted.
-    """
-    msg = ""
-    if "clear-session" in request.values:
-        clear_session()
-        msg = "Your session was cleared."
-
-    aggregate_groups = ["renewables", "EVSE"]
-    asset_groups = get_asset_group_queries(custom_additional_groups=aggregate_groups)
-    map_asset_groups = {}
-    for asset_group_name in asset_groups:
-        asset_group = Resource(asset_group_name)
-        map_asset_groups[asset_group_name] = asset_group
-
-    # Pack CDN resources (from pandas_bokeh/base.py)
-    bokeh_html_embedded = ""
-    for css in CDN.css_files:
-        bokeh_html_embedded += (
-            """<link href="%s" rel="stylesheet" type="text/css">\n""" % css
-        )
-    for js in CDN.js_files:
-        bokeh_html_embedded += """<script src="%s"></script>\n""" % js
-
-    return render_flexmeasures_template(
-        "views/dashboard.html",
-        message=msg,
-        bokeh_html_embedded=bokeh_html_embedded,
-        mapboxAccessToken=current_app.config.get("MAPBOX_ACCESS_TOKEN", ""),
-        map_center=get_center_location(user=current_user),
-        asset_groups=map_asset_groups,
-        aggregate_groups=aggregate_groups,
-    )
diff --git a/flexmeasures/ui/views/new_dashboard.py b/flexmeasures/ui/views/new_dashboard.py
index d688d485d..200707fcd 100644
--- a/flexmeasures/ui/views/new_dashboard.py
+++ b/flexmeasures/ui/views/new_dashboard.py
@@ -1,7 +1,6 @@
 from flask import request, current_app
 from flask_security import login_required
 from flask_security.core import current_user
-from bokeh.resources import CDN
 from flexmeasures.auth.policy import user_has_admin_access
 
 from flexmeasures.ui.views import flexmeasures_ui
@@ -19,7 +18,7 @@
 # Dashboard (default root view, see utils/app_utils.py)
 @flexmeasures_ui.route("/dashboard")
 @login_required
-def new_dashboard_view():
+def dashboard_view():
     """Dashboard view.
     This is the default landing page.
     It shows a map with the location of all of the assets in the user's account,
@@ -53,19 +52,9 @@ def new_dashboard_view():
 
     known_asset_types = [gat.name for gat in GenericAssetType.query.all()]
 
-    # Pack CDN resources (from pandas_bokeh/base.py)
-    bokeh_html_embedded = ""
-    for css in CDN.css_files:
-        bokeh_html_embedded += (
-            """<link href="%s" rel="stylesheet" type="text/css">\n""" % css
-        )
-    for js in CDN.js_files:
-        bokeh_html_embedded += """<script src="%s"></script>\n""" % js
-
     return render_flexmeasures_template(
         "views/new_dashboard.html",
         message=msg,
-        bokeh_html_embedded=bokeh_html_embedded,
         mapboxAccessToken=current_app.config.get("MAPBOX_ACCESS_TOKEN", ""),
         map_center=get_center_location_of_assets(user=current_user),
         known_asset_types=known_asset_types,
diff --git a/flexmeasures/ui/views/portfolio.py b/flexmeasures/ui/views/portfolio.py
deleted file mode 100644
index 673d1b41b..000000000
--- a/flexmeasures/ui/views/portfolio.py
+++ /dev/null
@@ -1,336 +0,0 @@
-from datetime import datetime, timedelta
-from typing import Dict, List
-
-from flask import request, session, current_app
-from flask_security.core import current_user
-import pandas as pd
-import numpy as np
-from bokeh.embed import components
-import bokeh.palettes as palettes
-from bokeh.plotting import Figure
-
-from flexmeasures.auth.policy import ADMIN_ROLE
-from flexmeasures.auth.decorators import account_roles_accepted
-from flexmeasures.data.models.assets import Asset, Power
-from flexmeasures.data.models.markets import Price
-from flexmeasures.data.queries.portfolio import (
-    get_structure,
-    get_power_data,
-    get_price_data,
-)
-from flexmeasures.data.services.resources import get_assets
-from flexmeasures.utils import time_utils
-from flexmeasures.utils.flexmeasures_inflection import capitalize
-import flexmeasures.ui.utils.plotting_utils as plotting
-from flexmeasures.ui.views import flexmeasures_ui
-from flexmeasures.ui.utils.view_utils import (
-    render_flexmeasures_template,
-    set_time_range_for_session,
-)
-
-"""
-This view is considered legacy by now. It is too specific to a use case and also
-relies on Bokeh. It also contains mock code.
-We might re-implement with Altair, as an account portfolio overview is handy.
-Maybe for all sensors sharing some trait, for instance all power sensors.
-When removing this, also remove the template.
-"""
-
-
-@flexmeasures_ui.route("/portfolio", methods=["GET", "POST"])
-@account_roles_accepted("Prosumer")
-def portfolio_view():  # noqa: C901
-    """Portfolio view.
-    By default, this page shows live results (production, consumption and market data) from the user's portfolio.
-    Time windows for which the platform has identified upcoming balancing opportunities are highlighted.
-    The page can also be used to navigate historical results.
-    """
-
-    set_time_range_for_session()
-    start = session.get("start_time")
-    end = session.get("end_time")
-    resolution = session.get("resolution")
-
-    # Get plot perspective
-    perspectives = ["production", "consumption"]
-    default_stack_side = "production"  # todo: move to user config setting
-    show_stacked = request.values.get("show_stacked", default_stack_side)
-    perspectives.remove(show_stacked)
-    show_summed: str = perspectives[0]
-    plot_label = f"Stacked {show_stacked} vs aggregated {show_summed}"
-
-    # Get structure and data
-    assets: List[Asset] = get_assets(
-        order_by_asset_attribute="display_name", order_direction="asc"
-    )
-    represented_asset_types, markets, resource_dict = get_structure(assets)
-    for resource_name, resource in resource_dict.items():
-        resource.load_sensor_data(
-            [Power, Price],
-            start=start,
-            end=end,
-            resolution=resolution,
-            exclude_source_types=["scheduling script"],
-        )  # The resource caches the results
-    (
-        supply_resources_df_dict,
-        demand_resources_df_dict,
-        production_per_asset_type,
-        consumption_per_asset_type,
-        production_per_asset,
-        consumption_per_asset,
-    ) = get_power_data(resource_dict)
-    price_bdf_dict, average_price_dict = get_price_data(resource_dict)
-
-    # Pick a perspective for summing and for stacking
-    sum_dict = (
-        demand_resources_df_dict.values()
-        if show_summed == "consumption"
-        else supply_resources_df_dict.values()
-    )
-    power_sum_df = (
-        pd.concat(sum_dict, axis=1).sum(axis=1).to_frame(name="event_value")
-        if sum_dict
-        else pd.DataFrame()
-    )
-
-    # Create summed plot
-    power_sum_df = data_or_zeroes(power_sum_df, start, end, resolution)
-    x_range = plotting.make_range(
-        pd.date_range(start, end, freq=resolution, closed="left")
-    )
-    fig_profile = plotting.create_graph(
-        power_sum_df,
-        unit="MW",
-        title=plot_label,
-        x_range=x_range,
-        x_label="Time (resolution of %s)"
-        % time_utils.freq_label_to_human_readable_label(resolution),
-        y_label="Power (in MW)",
-        legend_location="top_right",
-        legend_labels=(capitalize(show_summed), None, None),
-        show_y_floats=True,
-        non_negative_only=True,
-    )
-    fig_profile.plot_height = 450
-    fig_profile.plot_width = 900
-
-    # Create stacked plot
-    stack_dict = (
-        rename_event_value_column_to_resource_name(supply_resources_df_dict).values()
-        if show_summed == "consumption"
-        else rename_event_value_column_to_resource_name(
-            demand_resources_df_dict
-        ).values()
-    )
-    df_stacked_data = pd.concat(stack_dict, axis=1) if stack_dict else pd.DataFrame()
-    df_stacked_data = data_or_zeroes(df_stacked_data, start, end, resolution)
-    df_stacked_areas = stack_df(df_stacked_data)
-
-    num_areas = df_stacked_areas.shape[1]
-    if num_areas <= 2:
-        colors = ["#99d594", "#dddd9d"]
-    else:
-        colors = palettes.brewer["Spectral"][num_areas]
-
-    df_stacked_data = time_utils.tz_index_naively(df_stacked_data)
-    x_points = np.hstack((df_stacked_data.index[::-1], df_stacked_data.index))
-
-    fig_profile.grid.minor_grid_line_color = "#eeeeee"
-
-    for a, area in enumerate(df_stacked_areas):
-        fig_profile.patch(
-            x_points,
-            df_stacked_areas[area].values,
-            color=colors[a],
-            alpha=0.8,
-            line_color=None,
-            legend=df_stacked_data.columns[a],
-            level="underlay",
-        )
-
-    portfolio_plots_script, portfolio_plots_divs = components(fig_profile)
-
-    # Flexibility numbers and a mocked control action are mocked for demo mode at the moment
-    flex_info = {}
-    if current_app.config.get("FLEXMEASURES_MODE") == "demo":
-        flex_info = mock_flex_info(assets, represented_asset_types)
-        fig_actions = mock_flex_figure(
-            x_range, power_sum_df.index, fig_profile.plot_width
-        )
-        mock_flex_action_in_main_figure(fig_profile)
-        portfolio_plots_script, portfolio_plots_divs = components(
-            (fig_profile, fig_actions)
-        )
-
-    return render_flexmeasures_template(
-        "views/portfolio.html",
-        assets=assets,
-        average_prices=average_price_dict,
-        asset_types=represented_asset_types,
-        markets=markets,
-        production_per_asset=production_per_asset,
-        consumption_per_asset=consumption_per_asset,
-        production_per_asset_type=production_per_asset_type,
-        consumption_per_asset_type=consumption_per_asset_type,
-        sum_production=sum(production_per_asset_type.values()),
-        sum_consumption=sum(consumption_per_asset_type.values()),
-        flex_info=flex_info,
-        portfolio_plots_script=portfolio_plots_script,
-        portfolio_plots_divs=portfolio_plots_divs,
-        alt_stacking=show_summed,
-        fm_mode=current_app.config.get("FLEXMEASURES_MODE"),
-    )
-
-
-def data_or_zeroes(df: pd.DataFrame, start, end, resolution) -> pd.DataFrame:
-    """Making really sure we have the structure to let the plots not fail"""
-    if df is None or df.empty:
-        return pd.DataFrame(
-            index=pd.date_range(
-                start=start,
-                end=end,
-                freq=resolution,
-                tz=time_utils.get_timezone(),
-                closed="left",
-            ),
-            columns=["event_value"],
-        ).fillna(0)
-    else:
-        return df.fillna(0)
-
-
-def stack_df(df: pd.DataFrame) -> pd.DataFrame:
-    """Stack columns of df cumulatively, include bottom"""
-    df_top = df.cumsum(axis=1)
-    df_bottom = df_top.shift(axis=1).fillna(0)[::-1]
-    df_stack = pd.concat([df_bottom, df_top], ignore_index=True)
-    return df_stack
-
-
-def rename_event_value_column_to_resource_name(
-    df_dict: Dict[str, pd.DataFrame]
-) -> Dict[str, pd.DataFrame]:
-    """Replace the column name "event_source" with the resource name, for each resource in the dictionary."""
-    return {
-        df_name: df.rename(columns={"event_value": capitalize(df_name)})
-        for df_name, df in df_dict.items()
-    }
-
-
-def mock_flex_info(assets, represented_asset_types) -> dict:
-    flex_info = dict(
-        curtailment_per_asset={a.name: 0.0 for a in assets},
-        shifting_per_asset={a.name: 0.0 for a in assets},
-        profit_loss_flexibility_per_asset={a.name: 0.0 for a in assets},
-        curtailment_per_asset_type={k: 0.0 for k in represented_asset_types.keys()},
-        shifting_per_asset_type={k: 0.0 for k in represented_asset_types.keys()},
-        profit_loss_flexibility_per_asset_type={
-            k: 0.0 for k in represented_asset_types.keys()
-        },
-    )
-
-    flex_info["shifting_per_asset"]["48_r"] = 1.1
-    flex_info["profit_loss_flexibility_per_asset"]["48_r"] = 76000.0
-    flex_info["shifting_per_asset_type"]["one-way EVSE"] = flex_info[
-        "shifting_per_asset"
-    ]["48_r"]
-    flex_info["profit_loss_flexibility_per_asset_type"]["one-way EVSE"] = flex_info[
-        "profit_loss_flexibility_per_asset"
-    ]["48_r"]
-    flex_info["curtailment_per_asset"]["hw-onshore"] = 1.3
-    flex_info["profit_loss_flexibility_per_asset"]["hw-onshore"] = 84000.0
-    flex_info["curtailment_per_asset_type"]["wind turbines"] = flex_info[
-        "curtailment_per_asset"
-    ]["hw-onshore"]
-    flex_info["profit_loss_flexibility_per_asset_type"]["wind turbines"] = flex_info[
-        "profit_loss_flexibility_per_asset"
-    ]["hw-onshore"]
-
-    flex_info["sum_curtailment"] = sum(flex_info["curtailment_per_asset_type"].values())  # type: ignore
-    flex_info["sum_shifting"] = sum(flex_info["shifting_per_asset_type"].values())  # type: ignore
-    flex_info["sum_profit_loss_flexibility"] = sum(  # type: ignore
-        flex_info["profit_loss_flexibility_per_asset_type"].values()  # type: ignore
-    )
-    return flex_info
-
-
-def mock_flex_figure(x_range, x_index, fig_width) -> Figure:
-    df_actions = pd.DataFrame(index=x_index, columns=["event_value"]).fillna(0)
-    next_action_hour4 = get_flex_action_hour(4)
-    if next_action_hour4 in df_actions.index:
-        if current_user.is_authenticated:
-            if current_user.has_role(ADMIN_ROLE):
-                df_actions.loc[next_action_hour4] = -2.4  # mock two actions
-            elif "wind" in current_user.email:
-                df_actions.loc[next_action_hour4] = -1.3  # mock one action
-            elif "charging" in current_user.email:
-                df_actions.loc[next_action_hour4] = -1.1  # mock one action
-
-    next_action_hour2 = get_flex_action_hour(2)
-    if next_action_hour2 in df_actions.index:
-        if next_action_hour2 < next_action_hour4 and (
-            current_user.is_authenticated
-            and (
-                current_user.has_role(ADMIN_ROLE)
-                or "wind" in current_user.email
-                or "charging" in current_user.email
-            )
-        ):
-            # mock the shift "payback" (actually occurs earlier in our mock example)
-            df_actions.loc[next_action_hour2] = 1.1
-
-    next_action_hour9 = get_flex_action_hour(9)
-    if next_action_hour9 in df_actions.index:
-        # mock some other ordered actions that are not in an opportunity hour any more
-        df_actions.loc[next_action_hour9] = 3.5
-
-    fig_actions = plotting.create_graph(
-        df_actions,
-        unit="MW",
-        title="Ordered balancing actions",
-        x_range=x_range,
-        y_label="Power (in MW)",
-    )
-    fig_actions.plot_height = 150
-    fig_actions.plot_width = fig_width
-    fig_actions.xaxis.visible = False
-
-    if current_user.is_authenticated and (
-        current_user.has_role(ADMIN_ROLE)
-        or "wind" in current_user.email
-        or "charging" in current_user.email
-    ):
-        plotting.highlight(
-            fig_actions,
-            next_action_hour4,
-            next_action_hour4 + timedelta(hours=1),
-            redirect_to="/control",
-        )
-    return fig_actions
-
-
-def mock_flex_action_in_main_figure(fig_profile: Figure):
-    # show when user has (possible) actions in order book for a time slot
-    if current_user.is_authenticated and (
-        current_user.has_role(ADMIN_ROLE)
-        or "wind" in current_user.email
-        or "charging" in current_user.email
-    ):
-        next_action_hour = get_flex_action_hour(4)
-        plotting.highlight(
-            fig_profile,
-            next_action_hour,
-            next_action_hour + timedelta(hours=1),
-            redirect_to="/control",
-        )
-
-
-def get_flex_action_hour(h: int) -> datetime:
-    """get the next hour from now on"""
-    this_hour = time_utils.get_most_recent_hour()
-    return [
-        dt
-        for dt in [this_hour + timedelta(hours=i) for i in range(1, 25)]
-        if dt.hour == h
-    ][0]
diff --git a/flexmeasures/ui/views/state.py b/flexmeasures/ui/views/state.py
deleted file mode 100644
index b49510f36..000000000
--- a/flexmeasures/ui/views/state.py
+++ /dev/null
@@ -1,90 +0,0 @@
-from typing import Optional
-
-from flask import request
-from flask_security import auth_required
-from webargs.flaskparser import use_kwargs
-
-from flexmeasures.auth.decorators import permission_required_for_context
-from flexmeasures.data.models.assets import Asset
-from flexmeasures.data.models.generic_assets import GenericAsset
-from flexmeasures.data.models.time_series import Sensor
-from flexmeasures.data.services.resources import can_access_asset
-from flexmeasures.api.common.schemas.sensors import SensorIdField
-from flexmeasures.api.common.schemas.generic_assets import AssetIdField
-from flexmeasures.ui.utils.plotting_utils import (
-    get_latest_power_as_plot as legacy_get_latest_power_as_plot,
-)
-from flexmeasures.utils.unit_utils import is_power_unit
-from flexmeasures.ui.charts.latest_state import get_latest_power_as_plot
-from flexmeasures.ui.views import flexmeasures_ui
-
-
-@flexmeasures_ui.route("/asset/<id>/state/")
-@use_kwargs({"asset": AssetIdField(data_key="id")}, location="path")
-@permission_required_for_context("read", arg_name="asset")
-def asset_state_view(id: str, asset: GenericAsset):
-    """Asset state view.
-    This returns a little html snippet with a plot of the most recent state of the
-    first found power sensor.
-    TODO: no need to only support power plots in the future. Might make it optional.
-    """
-    power_sensor: Optional[Sensor] = None
-    for sensor in asset.sensors:
-        if is_power_unit(sensor.unit):
-            power_sensor = sensor
-            break
-    if power_sensor is None:
-        return (
-            """<script type="text/javascript">
-        console.log("State not available. Asset %s has no power sensors.");
-        </script>"""
-            % id
-        )
-    time_str, plot_html_str = get_latest_power_as_plot(power_sensor, small=True)
-    return plot_html_str
-
-
-@flexmeasures_ui.route("/sensor/<id>/state")
-@use_kwargs({"sensor": SensorIdField(data_key="id")}, location="path")
-@permission_required_for_context("read", arg_name="sensor")
-def sensor_state_view(id: int, sensor: Sensor):
-    """Sensor state view.
-    This returns a little html snippet with a plot of the most recent state of the sensor.
-    Only works for power sensors at the moment.
-    TODO: no need to only support power plots in the future. Might make it optional.
-    """
-    if not is_power_unit(sensor.unit):
-        return """"<script type="text/javascript">
-        console.log("State not available. Sensor is not a power sensor.");
-        </script>"""
-    time_str, plot_html_str = get_latest_power_as_plot(sensor, small=True)
-    return plot_html_str
-
-
-@flexmeasures_ui.route("/state")
-@auth_required()
-def state_view():
-    """State view.
-    This returns a little html snippet with a plot of the most recent state of the asset.
-
-    TODO: This is legacy ― it uses the old database model.
-    """
-    asset_id = request.args.get("id")
-    try:
-        int(asset_id)
-    except ValueError:
-        return """<script type="text/javascript">
-        console.log("State not available. Asset not officially registered.");
-        </script>"""
-    # TODO: try Sensor, then Asset (legacy)?
-    asset = Asset.query.filter(Asset.id == asset_id).one_or_none()
-    if asset is None:
-        return """"<script type="text/javascript">
-        console.log("State not available. No asset found.");
-        </script>"""
-    if not can_access_asset(asset):
-        return """<script type="text/javascript">
-        console.log("State not available. No access rights for this asset.");
-        </script>"""
-    time_str, plot_html_str = legacy_get_latest_power_as_plot(asset, small=True)
-    return plot_html_str
diff --git a/requirements/app.in b/requirements/app.in
index 63af9a797..01825b6ad 100644
--- a/requirements/app.in
+++ b/requirements/app.in
@@ -1,11 +1,8 @@
 # see ui/utils/plotting_utils: separate_legend() and create_hover_tool()
 altair
-bokeh==1.0.4
 colour
 pscript
 pandas
-# pandas-bokeh 0.5 requires bokeh>=2.0, but bokeh still doesn't support sharing a legend across plots
-pandas-bokeh==0.4.3
 pint>=0.19.1
 py-moneyed
 iso8601
diff --git a/requirements/app.txt b/requirements/app.txt
index 07101867d..06b2e4d11 100644
--- a/requirements/app.txt
+++ b/requirements/app.txt
@@ -31,10 +31,6 @@ blinker==1.4
     #   flask-principal
     #   flask-security-too
     #   sentry-sdk
-bokeh==1.0.4
-    # via
-    #   -r requirements/app.in
-    #   pandas-bokeh
 certifi==2021.10.8
     # via
     #   requests
@@ -159,7 +155,6 @@ itsdangerous==2.0.1
 jinja2==3.0.3
     # via
     #   altair
-    #   bokeh
     #   flask
 joblib==1.1.0
     # via scikit-learn
@@ -193,7 +188,6 @@ numpy==1.22.2
     # via
     #   -r requirements/app.in
     #   altair
-    #   bokeh
     #   matplotlib
     #   pandas
     #   patsy
@@ -210,7 +204,6 @@ outcome==1.1.0
     # via trio
 packaging==21.3
     # via
-    #   bokeh
     #   matplotlib
     #   redis
     #   statsmodels
@@ -219,20 +212,15 @@ pandas==1.2.5
     # via
     #   -r requirements/app.in
     #   altair
-    #   pandas-bokeh
     #   statsmodels
     #   timely-beliefs
     #   timetomodel
-pandas-bokeh==0.4.3
-    # via -r requirements/app.in
 passlib==1.7.4
     # via flask-security-too
 patsy==0.5.2
     # via statsmodels
 pillow==9.0.1
-    # via
-    #   bokeh
-    #   matplotlib
+    # via matplotlib
 pint==0.19.1
     # via -r requirements/app.in
 ply==3.11
@@ -268,7 +256,6 @@ pyrsistent==0.18.1
 python-dateutil==2.8.2
     # via
     #   arrow
-    #   bokeh
     #   matplotlib
     #   pandas
     #   timetomodel
@@ -282,8 +269,6 @@ pytz==2021.3
     #   pandas
     #   timely-beliefs
     #   timetomodel
-pyyaml==6.0
-    # via bokeh
 redis==4.1.3
     # via
     #   -r requirements/app.in
@@ -317,7 +302,6 @@ sentry-sdk[flask]==1.5.5
 six==1.16.0
     # via
     #   bcrypt
-    #   bokeh
     #   flask-cors
     #   flask-marshmallow
     #   isodate
@@ -352,8 +336,6 @@ tldextract==3.1.2
     # via -r requirements/app.in
 toolz==0.11.2
     # via altair
-tornado==6.1
-    # via bokeh
 trio==0.19.0
     # via
     #   selenium


Test patch:
diff --git a/flexmeasures/ui/charts/latest_state.py b/flexmeasures/ui/charts/latest_state.py
deleted file mode 100644
index 2292c84fd..000000000
--- a/flexmeasures/ui/charts/latest_state.py
+++ /dev/null
@@ -1,119 +0,0 @@
-from typing import Tuple
-from datetime import datetime
-
-from flask import current_app
-from colour import Color
-import pandas as pd
-import pandas_bokeh
-from bokeh.models import BoxAnnotation
-
-
-from flexmeasures.data.services.time_series import convert_query_window_for_demo
-from flexmeasures.utils.time_utils import (
-    server_now,
-    localized_datetime_str,
-)
-from flexmeasures.data.models.time_series import Sensor
-
-
-def get_latest_power_as_plot(sensor: Sensor, small: bool = False) -> Tuple[str, str]:
-    """
-    Create a plot of a sensor's latest power measurement as an embeddable html string (incl. javascript).
-    First returned string is the measurement time, second string is the html string.
-
-    Assumes that the sensor has the "capacity_in_mw" attribute.
-
-    TODO: move to Altair.
-    """
-
-    if current_app.config.get("FLEXMEASURES_MODE", "") == "play":
-        before = None  # type:ignore
-    else:
-        before = server_now()
-        _, before = convert_query_window_for_demo((before, before))
-
-    latest_power = sensor.latest_state()
-    if not latest_power.empty:
-        latest_power_value = latest_power["event_value"].values[0]
-        if current_app.config.get("FLEXMEASURES_MODE", "") == "demo":
-            latest_power_datetime = (
-                latest_power.event_ends[0]
-                .to_pydatetime()
-                .replace(year=datetime.now().year)
-            )
-        else:
-            latest_power_datetime = latest_power.event_ends[0].to_pydatetime()
-        latest_measurement_time_str = localized_datetime_str(
-            latest_power_datetime + sensor.event_resolution
-        )
-    else:
-        latest_power_value = 0
-        latest_measurement_time_str = "time unknown"
-    if latest_power_value < 0:
-        consumption = True
-        latest_power_value *= -1
-    else:
-        consumption = False
-    capacity_in_mw = sensor.get_attribute("capacity_in_mw", latest_power_value)
-    data = {
-        latest_measurement_time_str if not small else "": [0],
-        "Capacity in use": [latest_power_value],
-        "Remaining capacity": [capacity_in_mw - latest_power_value],
-    }
-    if capacity_in_mw > 0:
-        percentage_capacity = latest_power_value / capacity_in_mw
-    else:
-        percentage_capacity = 0
-    df = pd.DataFrame(data)
-    p = df.plot_bokeh(
-        kind="bar",
-        x=latest_measurement_time_str if not small else "",
-        y=["Capacity in use", "Remaining capacity"],
-        stacked=True,
-        colormap=[
-            "%s"
-            % Color(
-                hue=0.3 * min(1.0, 3 / 2 * percentage_capacity),
-                saturation=1,
-                luminance=min(0.5, 1 - percentage_capacity * 3 / 4),
-            ).get_hex_l(),  # 0% red, 38% yellow, 67% green, >67% darker green
-            "#f7ebe7",
-        ],
-        alpha=0.7,
-        title=None,
-        xlabel=None,
-        ylabel="Power (%s)" % sensor.unit,
-        zooming=False,
-        show_figure=False,
-        hovertool=None,
-        legend=None,
-        toolbar_location=None,
-        figsize=(200, 400) if not small else (100, 100),
-        ylim=(0, capacity_in_mw),
-        xlim=(-0.5, 0.5),
-    )
-    p.xgrid.visible = False
-    for r in p.renderers:
-        try:
-            r.glyph.width = 1
-        except AttributeError:
-            pass
-    p.xaxis.ticker = []
-    p.add_layout(BoxAnnotation(bottom=0, top=capacity_in_mw, fill_color="#f7ebe7"))
-    plot_html_str = pandas_bokeh.embedded_html(p)
-    hover_tool_str = "%s at %s %s (%s%% capacity).\nLatest state at %s." % (
-        "Consuming"
-        if consumption
-        else "Running"
-        if latest_power_value == 0
-        else "Producing",
-        round(latest_power_value, 3),
-        sensor.unit,
-        round(100 * percentage_capacity),
-        latest_measurement_time_str,
-    )
-    return (
-        latest_measurement_time_str,
-        """<div data-toggle="tooltip" data-placement="bottom" title="%s">%s</div>"""
-        % (hover_tool_str, plot_html_str),
-    )
diff --git a/flexmeasures/ui/tests/test_views.py b/flexmeasures/ui/tests/test_views.py
index 4b17bb8ee..c2d13536b 100644
--- a/flexmeasures/ui/tests/test_views.py
+++ b/flexmeasures/ui/tests/test_views.py
@@ -1,5 +1,4 @@
 from flask import url_for
-from flask_security import SQLAlchemySessionUserDatastore
 
 from flexmeasures.ui.tests.utils import logout
 
@@ -9,8 +8,7 @@ def test_dashboard_responds(client, setup_assets, as_prosumer_user1):
         url_for("flexmeasures_ui.dashboard_view"), follow_redirects=True
     )
     assert dashboard.status_code == 200
-    assert b"This dashboard shows you" in dashboard.data
-    assert b"Status of my assets" in dashboard.data
+    assert b"Dashboard" in dashboard.data
 
 
 def test_dashboard_responds_only_for_logged_in_users(client, as_prosumer_user1):
@@ -21,14 +19,6 @@ def test_dashboard_responds_only_for_logged_in_users(client, as_prosumer_user1):
     assert b"Please log in" in dashboard.data
 
 
-def test_portfolio_responds(client, setup_assets, as_prosumer_user1):
-    portfolio = client.get(
-        url_for("flexmeasures_ui.portfolio_view"), follow_redirects=True
-    )
-    assert portfolio.status_code == 200
-    assert b"Portfolio status" in portfolio.data
-
-
 def test_assets_responds(client, requests_mock, as_prosumer_user1):
     requests_mock.get(
         "http://localhost//api/v3_0/assets?account_id=1",
@@ -46,21 +36,6 @@ def test_control_responds(client, as_prosumer_user1):
     assert b"Control actions" in control.data
 
 
-def test_analytics_responds(db, client, setup_assets, as_prosumer_user1):
-    analytics = client.get(
-        url_for("flexmeasures_ui.analytics_view"), follow_redirects=True
-    )
-    assert analytics.status_code == 200
-    assert b"Client analytics" in analytics.data
-
-    from flexmeasures.data.models.user import User, Role
-
-    user_datastore = SQLAlchemySessionUserDatastore(db.session, User, Role)
-    test_prosumer = user_datastore.find_user(email="test_prosumer_user@seita.nl")
-
-    assert str.encode(f"{test_prosumer.username}") in analytics.data
-
-
 def test_logout(client, as_prosumer_user1):
     logout_response = logout(client)
     assert b"Please log in" in logout_response.data