diff --git a/package.json b/package.json index 405982d86..93b4c016e 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "@ckeditor/ckeditor5-react": "^2.1.0", "@emotion/react": "^11.11.0", "@svgr/core": "^8.0.0", + "@tanstack/react-table": "^8.20.5", "airbnb-prop-types": "^2.16.0", "highcharts": "^9.1.0", "highcharts-react-official": "^3.0.0", @@ -87,7 +88,9 @@ "prop-types": "^15.7.2", "quick-lint-js": "^3.2.0", "react": "^16.8.0", + "react-bootstrap": "^2.10.6", "react-data-table-component": "^7.6.2", + "react-datepicker": "^7.5.0", "react-dom": "^16.8.0", "react-grid-layout": "^1.3.0", "react-markdown": "^8.0.7", diff --git a/src/clj/collect_earth_online/db/metrics.clj b/src/clj/collect_earth_online/db/metrics.clj new file mode 100644 index 000000000..7b35695dc --- /dev/null +++ b/src/clj/collect_earth_online/db/metrics.clj @@ -0,0 +1,106 @@ +(ns collect-earth-online.db.metrics + (:require + [triangulum.database :refer [call-sql sql-primitive]] + [triangulum.response :refer [data-response]] + [triangulum.logging :refer [log]] + [clojure.string :as str])) + +(defn validate-dates [params] + (let [start-date (:startDate params) + end-date (:endDate params)] + (cond + (and start-date end-date) {:valid true} + (and (not start-date) (not end-date)) + {:valid false + :message "Missing parameters: startDate and endDate are required."} + (not start-date) + {:valid false + :message "Missing parameter: startDate is required."} + (not end-date) + {:valid false + :message "Missing parameter: endDate is required."}))) + +(defn validation-error-response [message] + (data-response message {:status 400})) + +(defn show-metrics-user [user-id] + (try + (sql-primitive (call-sql "show_metrics_user" user-id)) + (catch Exception e + (log (ex-message e)) + (data-response "Internal server error." {:status 500})))) + + +(defn get-imagery-counts [{:keys [params session]}] + (let [validation (validate-dates params)] + (if (:valid validation) + (try + (let [start-date (:startDate params) + end-date (:endDate params)] + (->> (call-sql "get_imagery_counts" start-date end-date) + (mapv (fn [{:keys [imagery_id imagery_name user_plot_count start_date end_date]}] + {:imageryId imagery_id + :imageryName imagery_name + :plots user_plot_count + :startDate start_date + :endDate end_date})) + (data-response))) + (catch Exception e + (log (ex-message e)) + (data-response "Internal server error." {:status 500}))) + (validation-error-response (:message validation))))) + +(defn get-projects-with-gee [{:keys [params session]}] + (let [validation (validate-dates params)] + (if (:valid validation) + (try + (let [start-date (:startDate params) + end-date (:endDate params)] + (->> (call-sql "get_projects_with_gee" start-date end-date) + (mapv (fn [{:keys [show_gee_script project_count start_date end_date]}] + {:showGeeScript show_gee_script + :projects project_count + :startDate start_date + :endDate end_date})) + (data-response))) + (catch Exception e + (log (ex-message e)) + (data-response "Internal server error." {:status 500}))) + (validation-error-response (:message validation))))) + +(defn get-sample-plot-counts [{:keys [params session]}] + (let [validation (validate-dates params)] + (if (:valid validation) + (try + (let [start-date (:startDate params) + end-date (:endDate params)] + (->> (call-sql "get_sample_plot_counts" start-date end-date) + (mapv (fn [{:keys [user_plot_count total_sample_count distinct_project_count start_date end_date]}] + {:userPlots user_plot_count + :totalSamples total_sample_count + :distinctProjects distinct_project_count + :startDate start_date + :endDate end_date})) + (data-response))) + (catch Exception e + (log (ex-message e)) + (data-response "Internal server error." {:status 500}))) + (validation-error-response (:message validation))))) + +(defn get-project-count [{:keys [params session]}] + (let [validation (validate-dates params)] + (if (:valid validation) + (try + (let [start-date (:startDate params) + end-date (:endDate params)] + (->> (call-sql "get_project_count" start-date end-date) + (mapv (fn [{:keys [project_count start_date end_date]}] + {:projects project_count + :startDate (str start_date) + :endDate (str end_date)})) + (data-response))) + (catch Exception e + (log (ex-message e)) + (data-response "Internal server error." {:status 500}))) + (validation-error-response (:message validation))))) + diff --git a/src/clj/collect_earth_online/db/plots.clj b/src/clj/collect_earth_online/db/plots.clj index 03318b2c2..ec54b0eac 100644 --- a/src/clj/collect_earth_online/db/plots.clj +++ b/src/clj/collect_earth_online/db/plots.clj @@ -275,6 +275,7 @@ user-images (:userImages params) new-plot-samples (:newPlotSamples params) user-id (if review-mode? current-user-id session-user-id) + imagery-ids (tc/clj->jsonb (:imageryIds params)) ;; Samples created in the UI have IDs starting with 1. When the new sample is created ;; in Postgres, it gets different ID. The user sample ID needs to be updated to match. id-translation (when new-plot-samples @@ -297,7 +298,9 @@ (when confidence-comment confidence-comment) (when-not review-mode? (Timestamp. collection-start)) (tc/clj->jsonb (set/rename-keys user-samples id-translation)) - (tc/clj->jsonb (set/rename-keys user-images id-translation))) + (tc/clj->jsonb (set/rename-keys user-images id-translation)) + imagery-ids) + (call-sql "delete_user_plot_by_plot" plot-id user-id)) (unlock-plots user-id) (data-response ""))) diff --git a/src/clj/collect_earth_online/handlers.clj b/src/clj/collect_earth_online/handlers.clj index 381c324bb..71b2278a6 100644 --- a/src/clj/collect_earth_online/handlers.clj +++ b/src/clj/collect_earth_online/handlers.clj @@ -1,6 +1,7 @@ (ns collect-earth-online.handlers (:require [collect-earth-online.db.institutions :refer [is-inst-admin?]] [collect-earth-online.db.projects :refer [can-collect? is-proj-admin?]] + [collect-earth-online.db.metrics :refer [show-metrics-user]] [ring.util.codec :refer [url-encode]] [ring.util.response :refer [redirect]] [triangulum.config :refer [get-config]] @@ -22,6 +23,7 @@ (pos? project-id) (is-proj-admin? user-id project-id token-key) (pos? institution-id) (is-inst-admin? user-id institution-id)) :no-cross (no-cross-traffic? headers) + :metrics (show-metrics-user user-id) true))) (defn redirect-handler [{:keys [session query-string uri] :as _request}] diff --git a/src/clj/collect_earth_online/routing.clj b/src/clj/collect_earth_online/routing.clj index f0c4a22f8..f906973a6 100644 --- a/src/clj/collect_earth_online/routing.clj +++ b/src/clj/collect_earth_online/routing.clj @@ -3,6 +3,7 @@ [collect-earth-online.db.geodash :as geodash] [collect-earth-online.db.imagery :as imagery] [collect-earth-online.db.institutions :as institutions] + [collect-earth-online.db.metrics :as metrics] [collect-earth-online.db.plots :as plots] [collect-earth-online.db.projects :as projects] [collect-earth-online.db.qaqc :as qaqc] @@ -55,6 +56,8 @@ [:get "/widget-layout-editor"] {:handler (render-page "/widget-layout-editor") :auth-type :admin :auth-action :redirect} + [:get "/metrics"] {:handler (render-page "/metrics")} + ;; Users API [:get "/get-institution-users"] {:handler users/get-institution-users :auth-type :user @@ -219,4 +222,18 @@ :auth-action :block} [:get "/get-nicfi-tiles"] {:handler proxy/get-nicfi-tiles :auth-type :no-cross - :auth-action :block}}) + :auth-action :block} + + ;; Metrics + [:get "/metrics/get-imagery-access"] {:handler metrics/get-imagery-counts + :auth-type :metrics + :auth-action :block} + [:get "/metrics/get-projects-with-gee"] {:handler metrics/get-projects-with-gee + :auth-type :metrics + :auth-action :block} + [:get "/metrics/get-sample-plot-counts"] {:handler metrics/get-sample-plot-counts + :auth-type :metrics + :auth-action :block} + [:get "/metrics/get-project-count"] {:handler metrics/get-project-count} + :auth-type :metrics + :auth-action :block}) diff --git a/src/css/metrics.css b/src/css/metrics.css new file mode 100644 index 000000000..a0fe7ac3f --- /dev/null +++ b/src/css/metrics.css @@ -0,0 +1,82 @@ +.metrics-dashboard { + padding: 20px; +} + +.dashboard-header { + background-color: #004d40; + padding: 20px; + color: white; + text-align: center; + margin-bottom: 20px; +} + +.filters { + display: flex; + flex-wrap: wrap; + gap: 15px; + margin-bottom: 20px; +} + +.filter-group { + display: flex; + gap: 15px; + flex: 1; +} + +.filter { + display: flex; + flex-direction: column; + gap: 5px; + flex: 1; +} + +.filter label { + font-size: 18px; + font-weight: bold; +} + +.date-picker { + padding: 5px; + border: 1px solid #ccc; + border-radius: 4px; +} + +.download-csv-btn { + background-color: #004d40; + color: white; + padding: 10px; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.download-csv-btn:hover { + background-color: #00332e; +} + +.table-container { + margin-top: 20px; +} + +.metrics-table { + width: 100%; + border-collapse: collapseg + +.metrics-table th, +.metrics-table td { + border: 1px solid #ccc; + padding: 8px; + text-align: left; +} + +.metrics-table th { + background-color: #004d40; + color: white; +} + +.custom-dropdown { + background-color: #F0EBEB !important; + border-color: #F0EBEB !important; + color: black; + text-align: left; +} diff --git a/src/js/collection.jsx b/src/js/collection.jsx index ec5e12acf..e152fceea 100644 --- a/src/js/collection.jsx +++ b/src/js/collection.jsx @@ -47,6 +47,7 @@ class Collection extends React.Component { imageryAttribution: "", // attributes to record when sample is saved imageryAttributes: {}, + imageryIds: [], imageryList: [], inReviewMode: false, mapConfig: null, @@ -178,6 +179,11 @@ class Collection extends React.Component { (this.state.currentImagery.id !== prevState.currentImagery.id || this.state.mapConfig !== prevState.mapConfig) ) { + if (!prevState.imageryIdsArray.includes(this.state.currentImagery.id)) { + this.setState((prevState) => ({ + imageryIds: [...prevState.imageryIds, this.state.currentImagery.id], + })); + } this.updateMapImagery(); } } @@ -763,6 +769,7 @@ class Collection extends React.Component { this.state.currentProject.allowDrawnSamples && this.state.currentPlot.samples, inReviewMode: this.state.inReviewMode, currentUserId: this.state.currentUserId, + imageryIds: this.state.imageryIds }), }).then((response) => { if (response.ok) { diff --git a/src/js/metrics.jsx b/src/js/metrics.jsx new file mode 100644 index 000000000..a29fec49f --- /dev/null +++ b/src/js/metrics.jsx @@ -0,0 +1,248 @@ +import React, { useState, useEffect } from 'react'; +import ReactDOM from "react-dom"; +import { Dropdown } from 'react-bootstrap'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import { + useReactTable, + getCoreRowModel, + flexRender, +} from "@tanstack/react-table"; + +import { NavigationBar } from "./components/PageComponents"; +import "../css/metrics.css"; + +const MetricsDashboard = () => { + const [metric, setMetric] = useState('Select a Metric'); + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + const [data, setData] = useState([]); + const [columns, setColumns] = useState([]); + const [handlerUrl, setHandlerUrl] = useState(''); + const [alertMessage, setAlertMessage] = useState(''); + + const metricsRoutes = { + getImageryAccess: { + displayName: "Imagery Access Metrics", + route: "/metrics/get-imagery-access" + }, + getProjectsWithGEE: { + displayName: "Projects with GEE Metrics", + route: "/metrics/get-projects-with-gee" + }, + getSamplePlotCounts: { + displayName: "Sample Plot Counts Metrics", + route: "/metrics/get-sample-plot-counts" + }, + getProjectCount: { + displayName: "Project Count Metrics", + route: "/metrics/get-project-count" + } + }; + + const buildUrl = (baseUrl, startDate, endDate) => { + const url = new URL(baseUrl, window.location.origin); + + // Extract only the date part in 'YYYY-MM-DD' format + if (startDate) { + const formattedStartDate = startDate.toISOString().split('T')[0]; // 'YYYY-MM-DD' + url.searchParams.append('startDate', formattedStartDate); + } + if (endDate) { + const formattedEndDate = endDate.toISOString().split('T')[0]; // 'YYYY-MM-DD' + url.searchParams.append('endDate', formattedEndDate); + } + + return url.toString(); + }; + + const fetchData = async () => { + if (!handlerUrl || !startDate || !endDate) { + setAlertMessage("⚠️ Please select a metric, start date, and end date before proceeding."); + return; + } + + setAlertMessage(''); // Clear previous alerts + + try { + const ceoUrl = buildUrl(handlerUrl, startDate, endDate); + const response = await fetch(ceoUrl); + const result = await response.json(); + + if (result.length === 0) { + setAlertMessage("🚫 No data found for the selected filters."); + setData([]); // Clear previous data + setColumns([]); // Clear previous columns + return; + } + + const formatColumnName = (name) => + name + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/^./, (char) => char.toUpperCase()); + + const cols = Object.keys(result[0]).map((key) => ({ + accessorKey: key, + header: formatColumnName(key), + })); + + setColumns(cols); + setData(result); + } catch (error) { + console.error("Error fetching data:", error); + setAlertMessage(`❌ Error fetching data: ${error.message}`); + } + }; + + // Table instance + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + const handleMetricSelect = (selectedMetric) => { + setMetric(selectedMetric.displayName); + setHandlerUrl(selectedMetric.route); + setData([]); // Clear previous data + setColumns([]); // Clear previous columns + }; + + const handleDownloadCSV = () => { + if (data.length === 0) { + alert("No data available to download."); + return; + } + + // Generate CSV content + const headers = columns.map((col) => col.header).join(",") + "\n"; + const rows = data + .map((row) => columns.map((col) => row[col.accessorKey]).join(",")) + .join("\n"); + + const csvContent = "data:text/csv;charset=utf-8," + headers + rows; + + // Trigger download + const encodedUri = encodeURI(csvContent); + const link = document.createElement("a"); + link.setAttribute("href", encodedUri); + link.setAttribute("download", "metrics_data.csv"); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + return ( +
+
+

Metrics Dashboard

+
+
+
+
+ + + + {metric} + + + {Object.values(metricsRoutes).map((metricItem) => ( + handleMetricSelect(metricItem)} + > + {metricItem.displayName} + + ))} + + +
+
+
+
+ + setStartDate(date)} + className="form-control" + dateFormat="yyyy-MM-dd" + /> +
+ +
+ + setEndDate(date)} + className="form-control" + dateFormat="yyyy-MM-dd" + /> +
+ +
+ +
+
+
+ {alertMessage ? ( +
+ {alertMessage} +
+ ) : ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ )} +
+
+
+ ); +}; + +export function pageInit(params, session) { + ReactDOM.render( + + + , + document.getElementById("app") + ); +} diff --git a/src/js/survey/SurveyCollection.jsx b/src/js/survey/SurveyCollection.jsx index 7c71548ee..069b41c6a 100644 --- a/src/js/survey/SurveyCollection.jsx +++ b/src/js/survey/SurveyCollection.jsx @@ -101,14 +101,14 @@ export default class SurveyCollection extends React.Component { getTopColor = (nodeId) => { if (this.checkAllSubAnswers(nodeId)) { - return "0px 0px 6px 4px #3bb9d6 inset"; + return "btn-outline-info"; } else { const { surveyQuestions } = this.props; const { answered } = surveyQuestions[nodeId]; if (answered.length) { - return "0px 0px 6px 4px yellow inset"; + return "btn-outline-warning"; } else { - return "0px 0px 6px 4px red inset"; + return "btn-outline-danger"; } } }; @@ -440,6 +440,88 @@ export default class SurveyCollection extends React.Component { } }; + getBoxShadowColor = (nodeId) => { + if (this.checkAllSubAnswers(nodeId)) { + return "rgba(23, 162, 184, 0.5)"; // Info color + } else { + const { surveyQuestions } = this.props; + const { answered } = surveyQuestions[nodeId]; + if (answered.length) { + return "rgba(255, 193, 7, 0.5)"; // Warning color + } else { + return "rgba(220, 53, 69, 0.5)"; // Danger color + } + } + }; + + renderControls = () => { + const getBoxShadowColor = (nodeId) => { + if (this.checkAllSubAnswers(nodeId)) { + return "rgba(23, 162, 184, 0.5)"; // Info color + } else { + const { surveyQuestions } = this.props; + const { answered } = surveyQuestions[nodeId]; + if (answered.length) { + return "rgba(255, 193, 7, 0.5)"; // Warning color + } else { + return "rgba(220, 53, 69, 0.5)"; // Danger color + } + } + }; + + return ( +
+ + {this.state.topLevelNodeIds.map((nodeId, i) => ( + + ))} + +
+ ); + } + renderQuestions = () => (
{this.unansweredColor()} @@ -459,53 +541,7 @@ export default class SurveyCollection extends React.Component { ) : ( <> -
- - {this.state.topLevelNodeIds.map((nodeId, i) => ( - - ))} - -
+ {this.renderControls()} {this.state.topLevelNodeIds.length > 0 && ( )} + {this.renderControls()} )}
diff --git a/src/sql/changes/2024-10-30-add-imagery-ids.sql b/src/sql/changes/2024-10-30-add-imagery-ids.sql new file mode 100644 index 000000000..d8238f061 --- /dev/null +++ b/src/sql/changes/2024-10-30-add-imagery-ids.sql @@ -0,0 +1,2 @@ +ALTER TABLE user_plots +ADD imagery_ids jsonb; diff --git a/src/sql/changes/2024-12-12-metrics-users.sql b/src/sql/changes/2024-12-12-metrics-users.sql new file mode 100644 index 000000000..134d97797 --- /dev/null +++ b/src/sql/changes/2024-12-12-metrics-users.sql @@ -0,0 +1,5 @@ +CREATE TABLE metrics_users ( + metric_users_uid SERIAL PRIMARY KEY, + user_rid INTEGER NOT NULL REFERENCES users (user_uid) ON DELETE CASCADE ON UPDATE CASCADE, + created_date DATE DEFAULT NOW() +); diff --git a/src/sql/functions/member.sql b/src/sql/functions/member.sql index 2bd04511d..684b0491c 100644 --- a/src/sql/functions/member.sql +++ b/src/sql/functions/member.sql @@ -60,7 +60,14 @@ CREATE OR REPLACE FUNCTION can_user_edit_project(_user_id integer, _project_id i $$ LANGUAGE SQL; --- +CREATE OR REPLACE FUNCTION show_metrics_user(_user_id integer) + RETURNS boolean AS $$ + + SELECT count(1) > 0 + FROM metrics_users + +$$ LANGUAGE SQL; + -- USER FUNCTIONS -- diff --git a/src/sql/functions/metrics.sql b/src/sql/functions/metrics.sql new file mode 100644 index 000000000..d9002880d --- /dev/null +++ b/src/sql/functions/metrics.sql @@ -0,0 +1,122 @@ +CREATE OR REPLACE FUNCTION get_imagery_counts(start_date_param TEXT, end_date_param TEXT) +RETURNS TABLE ( + imagery_id INT, + imagery_name TEXT, + user_plot_count BIGINT, + start_date TEXT, + end_date TEXT +) AS $$ +BEGIN + RETURN QUERY + SELECT + i.imagery_uid AS imagery_id, + i.title AS imagery_name, + COUNT(up.user_plot_uid) AS user_plot_count, + start_date_param AS start_date, + end_date_param AS end_date + FROM + user_plots up + JOIN + plots p ON up.plot_rid = p.plot_uid + JOIN + projects proj ON p.project_rid = proj.project_uid + LEFT JOIN + imagery i ON proj.imagery_rid = i.imagery_uid + WHERE + up.collection_start BETWEEN start_date_param::DATE AND end_date_param::DATE + GROUP BY + i.imagery_uid, i.title + ORDER BY + user_plot_count DESC; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION get_projects_with_gee(start_date_param TEXT, end_date_param TEXT) +RETURNS TABLE ( + show_gee_script BOOLEAN, + project_count BIGINT, + start_date TEXT, + end_date TEXT +) AS $$ +BEGIN + RETURN QUERY + SELECT + (CASE + WHEN (options->>'showGEEScript')::BOOLEAN IS TRUE THEN TRUE + ELSE FALSE + END) AS show_gee_script, + COUNT(*) AS project_count, + start_date_param AS start_date, + end_date_param AS end_date + FROM + projects + WHERE + created_date BETWEEN start_date_param::DATE AND end_date_param::DATE + GROUP BY + show_gee_script; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION get_sample_plot_counts(start_date_param TEXT, end_date_param TEXT) +RETURNS TABLE ( + user_plot_count BIGINT, + total_sample_count BIGINT, + distinct_project_count BIGINT, + start_date TEXT, + end_date TEXT +) AS $$ +BEGIN + RETURN QUERY + SELECT + COUNT(up.user_plot_uid) AS user_plot_count, + COUNT(DISTINCT p.project_rid) AS distinct_project_count, + COALESCE(SUM(s.sample_count)::BIGINT, 0) AS total_sample_count, + start_date_param AS start_date, + end_date_param AS end_date + FROM + user_plots up + JOIN + plots p ON up.plot_rid = p.plot_uid + LEFT JOIN ( + SELECT + plot_rid, + COUNT(*) AS sample_count + FROM + samples + GROUP BY + plot_rid + ) s ON p.plot_uid = s.plot_rid + WHERE + up.collection_start BETWEEN start_date_param::DATE AND end_date_param::DATE; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION get_project_count(start_date_param TEXT, end_date_param TEXT) +RETURNS TABLE ( + project_count BIGINT, + start_date TEXT, + end_date TEXT +) AS $$ +BEGIN + RETURN QUERY + SELECT + COUNT(*) AS project_count, + start_date_param AS start_date, + end_date_param AS end_date + FROM + projects + WHERE + created_date BETWEEN start_date_param::DATE AND end_date_param::DATE; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION show_metrics_user(user_id INTEGER) +RETURNS BOOLEAN AS $$ +BEGIN + RETURN EXISTS ( + SELECT 1 + FROM metric_users + WHERE user_rid = user_id + ); +END; +$$ LANGUAGE plpgsql; diff --git a/src/sql/functions/plots.sql b/src/sql/functions/plots.sql index 3003d408f..de7f641a5 100644 --- a/src/sql/functions/plots.sql +++ b/src/sql/functions/plots.sql @@ -436,14 +436,15 @@ CREATE OR REPLACE FUNCTION upsert_user_samples( _confidence_comment text, _collection_start timestamp, _samples jsonb, - _images jsonb + _images jsonb, + _imageryIds jsonb ) RETURNS integer AS $$ WITH user_plot_table AS ( INSERT INTO user_plots AS up - (user_rid, plot_rid, confidence, confidence_comment ,collection_start, collection_time) + (user_rid, plot_rid, confidence, confidence_comment ,collection_start, collection_time, imagery_ids) VALUES - (_user_id, _plot_id, _confidence, _confidence_comment , _collection_start, Now()) + (_user_id, _plot_id, _confidence, _confidence_comment , _collection_start, Now(), _imageryIds) ON CONFLICT (user_rid, plot_rid) DO UPDATE SET confidence = coalesce(excluded.confidence, up.confidence), diff --git a/vite.config.js b/vite.config.js index 23e720a42..70fe8a2dd 100644 --- a/vite.config.js +++ b/vite.config.js @@ -51,7 +51,8 @@ export default defineConfig({ "src/js/termsOfService.jsx", "src/js/userDisagreement.jsx", "src/js/verifyEmail.jsx", - "src/js/widgetLayoutEditor.jsx" + "src/js/widgetLayoutEditor.jsx", + "src/js/metrics.jsx" ], output: { // compact: false,