← Files DataARCHIVED FILE

templates/data-app/base/src/use-data-app.js

15.9 KB · Sep 30, 2026 · 23:19 UTC

↓ Download file

import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";

import { isTemporalField } from "./source-provenance.js";

const queryUpdateProperties = {
  queryId: { type: "string" },
  rows: { type: "array", maxItems: 10_000, items: { type: "object" } },
  executedAt: { type: "string", format: "date-time", description: "When the source request finished, including timezone." },
};

const noVisibleReportFilters = [];
const noSubscribe = () => () => {};
const zeroVersion = () => 0;

export function listReviewedQueries(queries, queryMetadata) {
  return Object.entries(queries).map(([queryId, { rows, ...definition }]) => {
    const source = definition.source ?? {};
    const metadata = queryMetadata?.[queryId];
    return {
      ...definition, queryId,
      label: source.label, sql: source.sql, tables: source.tables ?? [],
      columns: Array.isArray(rows) ? [...new Set(rows.flatMap(Object.keys))] : metadata?.columns ?? [],
      rowCount: Array.isArray(rows) ? rows.length : metadata?.rowCount ?? 0,
      ...(queryMetadata ? { loaded: Array.isArray(rows) } : {}),
    };
  });
}

export function filterReviewedRows(rows, definitions, filters, queryId, breakdown = []) {
  let selectedRows = rows;
  const applicable = definitions.filter(({ queryIds }) => !Array.isArray(queryIds) || queryIds.includes(queryId));
  filters = Object.fromEntries(applicable.map(({ id, defaultValue, multiple }) => {
    const value = filters[id] ?? defaultValue ?? "all";
    return [id, multiple && Array.isArray(value) && value.length === 0 ? "all" : value];
  }));

  const latestFields = [];

  // Apply exact dimensions before choosing the latest available date in a range.
  const ordered = [...applicable].sort((a, b) =>
    Number(String(filters[a.id] ?? "").includes("..")) - Number(String(filters[b.id] ?? "").includes("..")));
  ordered.forEach(({ id, field, mode, operator, multiple }) => {
    if (filters[id] !== "all" && selectedRows.some((row) => Object.hasOwn(row, field))) {
      const selected = filters[id];
      if (multiple && Array.isArray(selected)) {
        const choices = new Set(selected);
        selectedRows = selectedRows.filter(row => choices.has(String(row[field])));
        return;
      }
      const ranged = typeof selected === "string" && selected.includes("..");
      const [start, end] = ranged ? selected.split("..") : [undefined, selected];
      const withinRange = selectedRows.filter((row) => String(row[field]) <= end
        && (!start || String(row[field]) >= start));
      if ((mode === "through" || operator === "lte") && breakdown.includes(field)) {
        selectedRows = withinRange;
      } else if (ranged) {
        selectedRows = withinRange;
        latestFields.push(field);
      } else {
        selectedRows = selectedRows.filter((row) => String(row[field]) === selected);
      }
    }
  });

  applicable.forEach(({ id, field }) => {
    if (filters[id] === "all" && breakdown.includes(field)
      && selectedRows.some((row) => String(row[field] ?? "").toLowerCase() === "all")
      && selectedRows.some((row) => Object.hasOwn(row, field)
        && String(row[field]).toLowerCase() !== "all")) {
      selectedRows = selectedRows.filter((row) => String(row[field]).toLowerCase() !== "all");
    }
  });

  applicable.forEach(({ id, field }) => {
    if (filters[id] === "all" && !breakdown.includes(field)
      && selectedRows.some((row) => String(row[field] ?? "").toLowerCase() === "all")) {
      selectedRows = selectedRows.filter((row) => String(row[field]).toLowerCase() === "all");
    }
  });

  // Select endpoints only after exact dimensions and aggregate-row preference.
  for (const field of latestFields) {
    const latest = selectedRows.reduce((last, row) => String(row[field]) > last ? String(row[field]) : last, "");
    selectedRows = selectedRows.filter((row) => String(row[field]) === latest);
  }
  return selectedRows;
}

// Resolve both scopes in one pass. Filtering already-collapsed global rows loses
// dimension detail; internal IDs also prevent page/section ID collisions.
export function resolveSectionRows(rows, pageDefinitions, pageValues, definitions, values, queryId, breakdown = []) {
  const combined = [...pageDefinitions.map((definition, index) => ({
    ...definition, id: `page:${index}`, value: pageValues[definition.id] ?? definition.defaultValue ?? "all",
  })), ...definitions.map((definition, index) => ({
    ...definition, id: `section:${index}`, value: values[definition.id] ?? definition.defaultValue ?? "all",
  }))];
  return filterReviewedRows(rows, combined, Object.fromEntries(combined.map(({ id, value }) => [id, value])), queryId, breakdown);
}

export function previousPeriodRows(rows, definitions, filters, queryId, periodField, currentPeriod) {
  if (currentPeriod == null) return [];
  const comparisonFilters = { ...filters };
  definitions.forEach(({ id, field }) => {
    if (field === periodField) comparisonFilters[id] = "all";
  });
  const prior = filterReviewedRows(rows, definitions, comparisonFilters, queryId)
    .filter((row) => row[periodField] < currentPeriod);
  const previousPeriod = prior.reduce((latest, row) => (
    row[periodField] > latest ? row[periodField] : latest
  ), "");
  return prior.filter((row) => row[periodField] === previousPeriod);
}

export function reportFilterVisible(filter, visibleFilterIds = []) {
  return filter.visible === true || filter.reportVisible === true
    || filter.surface === "report" || (Array.isArray(filter.surfaces) && filter.surfaces.includes("report"))
    || (Array.isArray(visibleFilterIds) && visibleFilterIds.includes(filter.id));
}

// Prefer explicit reporting metadata; ambiguous temporal columns are not a basis
// for inventing a latest/prior comparison.
export function reportPeriodField(rows, definitions, queryId, requestedField) {
  const columns = [...new Set(rows.flatMap(Object.keys))];
  if (requestedField) return columns.includes(requestedField) ? requestedField : undefined;
  const defined = [...new Set(definitions.filter(({ field, type, valueType, queryIds }) =>
    (!Array.isArray(queryIds) || queryIds.includes(queryId))
    && columns.includes(field) && isTemporalField(field, type ?? valueType)).map(({ field }) => field))];
  if (defined.length) return defined.length === 1 ? defined[0] : undefined;
  const inferred = columns.filter((field) => isTemporalField(field));
  return inferred.length === 1 ? inferred[0] : undefined;
}

function compareReportPeriods(left, right) {
  return String(left).localeCompare(String(right), undefined, { numeric: true });
}

// History respects visible report filters. Latest selects a reviewed period in that
// scope; previous clears only its reporting-field filters and keeps the same grain.
// With no unambiguous reporting field, history is available but comparisons are empty.
export function reportReviewedRows(rows, definitions, filters, queryId, {
  period = "history", periodField, currentPeriod, breakdown = [], visibleFilterIds = [],
} = {}) {
  if (!["history", "latest", "previous"].includes(period)) {
    throw new Error(`Unknown report reviewed-data period: ${period}`);
  }
  const field = reportPeriodField(rows, definitions, queryId, periodField);
  const values = Object.fromEntries(definitions.map((definition) => [definition.id,
    reportFilterVisible(definition, visibleFilterIds) ? filters[definition.id] ?? "all" : "all"]));
  const scopedBreakdown = [...new Set([...breakdown, ...(field ? [field] : [])])];
  // Report history retains every reviewed period inside a visible range. The shared
  // dashboard filter keeps its own latest-endpoint behavior unchanged.
  const scopedDefinitions = definitions.map((definition) => definition.field === field
    && typeof values[definition.id] === "string" && values[definition.id].includes("..")
    ? { ...definition, mode: "through" } : definition);
  const scoped = filterReviewedRows(rows, scopedDefinitions, values, queryId, scopedBreakdown);
  if (period === "history") return scoped;
  if (!field) return [];

  const periods = [...new Set(scoped.map((row) => row[field]).filter((value) => value != null))]
    .sort(compareReportPeriods);
  const selected = currentPeriod ?? periods.at(-1);
  if (selected == null) return [];
  if (period === "latest") return scoped.filter((row) => compareReportPeriods(row[field], selected) === 0);

  // A selected date or narrow range must not erase the preceding comparison period.
  // Keep every other visible dimension, query scope, and aggregate/breakdown choice.
  const comparisonValues = { ...values };
  definitions.forEach((definition) => {
    if (definition.field === field) comparisonValues[definition.id] = "all";
  });
  const candidates = filterReviewedRows(rows, definitions, comparisonValues, queryId, scopedBreakdown)
    .filter((row) => row[field] != null && compareReportPeriods(row[field], selected) < 0);
  const previous = candidates.map((row) => row[field]).sort(compareReportPeriods).at(-1);
  return previous == null ? [] : candidates.filter((row) => compareReportPeriods(row[field], previous) === 0);
}

export function reportAggregateRows(rows, definitions, filters, queryId, options = {}) {
  // Suppress breakdown rows without discarding an explicitly selected dimension.
  return reportReviewedRows(rows, definitions, filters, queryId, { ...options, breakdown: [] });
}

export function useDataApp(snapshot, {
  hosted = false,
  canEdit = true,
  onSnapshotChange,
  queryDataStore,
  initialFilters = {},
  authoritativeInitialFilters = {},
  filterDefinitions = snapshot.filters ?? [],
  visibleFilterIds = noVisibleReportFilters,
} = {}) {
  const [localQueries, setQueries] = useState(snapshot.queries);
  useSyncExternalStore(queryDataStore?.subscribe ?? noSubscribe, queryDataStore?.getVersion ?? zeroVersion,
    queryDataStore?.getVersion ?? zeroVersion);
  const queries = queryDataStore ? queryDataStore.getQueries() : localQueries;
  const queriesRef = useRef(snapshot.queries);
  queriesRef.current = queries;
  const [filters, setFilters] = useState(() => Object.fromEntries(
    (snapshot.filters ?? []).map(({ id, defaultValue }) => [id,
      initialFilters[id] === "all" && defaultValue && defaultValue !== "all"
        && !Object.hasOwn(authoritativeInitialFilters, id)
        ? defaultValue : initialFilters[id] ?? defaultValue ?? "all"]),
  ));

  useEffect(() => {
    if (!hosted || !canEdit) return;
    const context = document.modelContext ?? navigator.modelContext;
    if (typeof context?.registerTool !== "function") return;

    const tools = [{
      name: "list_data_app_queries",
      description: "List reviewed Data app queries, provenance, columns, and row counts.",
      inputSchema: { type: "object", properties: {}, additionalProperties: false },
      annotations: { readOnlyHint: true, untrustedContentHint: true },
      execute: async () => listReviewedQueries(queriesRef.current,
        queryDataStore ? snapshot._dataAppQueryLoading?.queries ?? {} : undefined),
    }, {
      name: "update_data_app_query",
      description: "Update reviewed Data app rows and source execution times. Use updates to commit every dataset in a refresh together.",
      inputSchema: {
        type: "object",
        oneOf: [{
          properties: queryUpdateProperties,
          required: ["queryId", "rows"], additionalProperties: false,
        }, {
          properties: { updates: {
            type: "array", minItems: 1, maxItems: 100,
            items: { type: "object", properties: queryUpdateProperties,
              required: ["queryId", "rows", "executedAt"], additionalProperties: false },
          } },
          required: ["updates"], additionalProperties: false,
        }],
      },
      execute: async (input) => {
        const batch = Object.hasOwn(input, "updates");
        // Owner edits retain the complete-data behavior, including palette and
        // source metadata. A failed load must not turn an edit into partial data.
        await queryDataStore?.loadAll();
        const response = await fetch(batch ? "/api/queries" : `/api/queries/${encodeURIComponent(input.queryId)}`, {
          method: "PUT",
          headers: { "content-type": "application/json" },
          body: JSON.stringify(batch ? input : { rows: input.rows, executedAt: input.executedAt }),
        });
        const result = await response.json();
        if (!response.ok) throw new Error(result.error || "Unable to update Data app data.");
        const updates = batch ? result.updates : [result];
        queriesRef.current = { ...(queryDataStore?.getQueries() ?? queriesRef.current) };
        for (const { queryId, rows, executedAt } of updates) {
          const query = queriesRef.current[queryId];
          queriesRef.current[queryId] = { ...query, rows,
            source: { ...query.source, ...(executedAt === undefined ? {} : { executedAt }) } };
          queryDataStore?.replace(queryId, rows, executedAt);
        }
        queriesRef.current = queryDataStore?.getQueries() ?? queriesRef.current;
        setQueries(queriesRef.current);
        const nextQueries = queriesRef.current;
        onSnapshotChange?.((current) => ({
          ...current, generatedAt: result.generatedAt, queries: nextQueries,
          ...(queryDataStore ? { _dataAppQueryLoading: undefined } : {}),
        }));
        const counts = updates.map(({ queryId, rows }) => ({ queryId, rowCount: rows.length }));
        return batch ? { updates: counts } : counts[0];
      },
    }];

    tools.forEach((tool) => {
      const report = (error) => console.error(`Unable to register Data app tool ${tool.name}.`, error);
      try {
        Promise.resolve(context.registerTool(tool)).catch(report);
      } catch (error) {
        report(error);
      }
    });
    return () => tools.forEach((tool) => context.unregisterTool?.(tool.name));
  }, [canEdit, hosted, onSnapshotChange, snapshot, queryDataStore]);

  const setFilter = useCallback((id, value) => {
    setFilters((current) => ({ ...current, [id]: value }));
  }, []);

  const replaceFilters = useCallback((values) => {
    setFilters((current) => {
      const next = Object.fromEntries((snapshot.filters ?? []).map(({ id, defaultValue }) =>
        [id, values[id] ?? defaultValue ?? "all"]));
      return Object.keys(next).length === Object.keys(current).length
        && Object.entries(next).every(([id, value]) => current[id] === value) ? current : next;
    });
  }, [snapshot.filters]);

  const report = snapshot.surface === "report";
  const reviewedRows = useCallback((queryId, breakdown = []) => {
    const rows = queries[queryId]?.rows ?? [];
    if (report) return reportReviewedRows(rows, snapshot.filters ?? [], filters, queryId, {
      periodField: queries[queryId]?.reportingField, breakdown, visibleFilterIds,
    });
    return filterReviewedRows(rows, filterDefinitions, filters, queryId, breakdown);
  }, [filters, queries, report, snapshot, visibleFilterIds, filterDefinitions]);

  const reviewedPeriodRows = useCallback((queryId, options = {}) => reportReviewedRows(
    queries[queryId]?.rows ?? [], snapshot.filters ?? [], filters, queryId,
    { periodField: queries[queryId]?.reportingField, visibleFilterIds, ...options },
  ), [filters, queries, snapshot, visibleFilterIds]);

  const reviewedAggregatePeriodRows = useCallback((queryId, options = {}) => reportAggregateRows(
    queries[queryId]?.rows ?? [], snapshot.filters ?? [], filters, queryId,
    { periodField: queries[queryId]?.reportingField, visibleFilterIds, ...options },
  ), [filters, queries, snapshot, visibleFilterIds]);

  const activeFilters = filterDefinitions
    .filter(filter => !report || reportFilterVisible(filter, visibleFilterIds))
    .filter(({ id }) => filters[id] != null && filters[id] !== "all"
      && (!Array.isArray(filters[id]) || filters[id].length > 0))
    .map(({ id, field, label, queryIds }) => ({
      field, label, value: typeof filters[id] === "string"
        ? filters[id].replace("..", " – ") : filters[id],
      ...(Array.isArray(queryIds) ? { queryIds } : {}),
    }));

  return {
    queries, filters, setFilter, replaceFilters, reviewedRows, reviewedPeriodRows,
    reviewedAggregatePeriodRows, activeFilters,
  };
}

SHA-256: 699f86eabcd0bb506162862e76b81951e4e0c875a1e78c276d39843bc3bb7702