Trend Chart Module

Visualize metric movement over time with a reusable trend chart module for dashboards and summary pages.

Custom CodeUI Design & LayoutsYeeflow
custom codetrend chartanalyticsdashboardtsx

What this template helps teams build

Overview

The Trend Chart Module template is a reusable custom code control concept for Yeeflow builders. It helps teams add a focused interface pattern for trend analysis and dashboard reporting while keeping the asset in review before public launch.

Key capabilities

  • Supports dashboard & analytics scenarios
  • Designed for use in Dashboard, Application page
  • Includes TSX source, user guidance, and example configuration
  • Prepared as a preview asset for technical review and future public documentation

Recommended use cases

  • trend analysis and dashboard reporting
  • Dashboard & Analytics template library planning
  • Internal builder education and implementation review

Governance notes

This record is created in review status for editorial and product validation. It should not be positioned as launchable until implementation, security, runtime, and documentation review are complete.

Build patterns behind this template

Use this template as a reference while reviewing the Custom Code developer guide. Learn how the required export structure, input parameters, and rendering patterns fit together.

Code preview

Download source file
import React from 'react';

type TrendPoint = {
  key: string;
  label: string;
  count: number;
  value: number;
  x: number;
  y: number;
};

export class CodeInApplication implements CodeInComp {
  description() {
    return 'Trend Chart Module - reusable Yeeflow dashboard custom code template for time-based list trends.';
  }

  requiredFields(params?: CodeInParams) {
    return [];
  }

  inputParameters(): InputParameter[] {
    return [
      {
        id: 'dataListId',
        name: 'Data List ID',
        type: 'variable',
        desc: 'Target Yeeflow data list ID used as the trend data source. Supports expression editor values.',
      },
      {
        id: 'dateField',
        name: 'Date Field',
        type: 'variable',
        desc: 'Field ID/name used as the time axis, such as CreatedTime, SubmitDate, CompletedDate, or AttendanceDate.',
      },
      {
        id: 'titleText',
        name: 'Title Text',
        type: 'string',
        desc: 'Optional module title shown above the chart.',
      },
      {
        id: 'subtitleText',
        name: 'Subtitle Text',
        type: 'string',
        desc: 'Optional supporting text shown below the title.',
      },
      {
        id: 'chartType',
        name: 'Chart Type',
        type: 'string',
        desc: 'Chart mode: line, area, areaLine, column, or bar. Default is line.',
      },
      {
        id: 'timeGranularity',
        name: 'Time Granularity',
        type: 'string',
        desc: 'Time bucket grouping: day, week, or month. Default is day.',
      },
      {
        id: 'maxPoints',
        name: 'Max Points',
        type: 'string',
        desc: 'Maximum number of time buckets shown. Default is 12.',
      },
      {
        id: 'showPointLabels',
        name: 'Show Point Labels',
        type: 'variable',
        desc: 'Whether value labels are shown on points or columns. Supports true/false or dynamic expression.',
      },
      {
        id: 'showXAxisLabels',
        name: 'Show X Axis Labels',
        type: 'variable',
        desc: 'Whether date bucket labels are shown on the x-axis. Supports true/false or dynamic expression.',
      },
      {
        id: 'showYAxis',
        name: 'Show Y Axis',
        type: 'variable',
        desc: 'Whether a simple y-axis and grid are shown. Supports true/false or dynamic expression.',
      },
      {
        id: 'emptyText',
        name: 'Empty Text',
        type: 'string',
        desc: 'Text shown when no valid dated records are available.',
      },
      {
        id: 'colorMode',
        name: 'Color Mode',
        type: 'string',
        desc: 'Color palette: yeeflow, green, amber, red, violet, or slate. Default is yeeflow.',
      },
      {
        id: 'sortMode',
        name: 'Sort Mode',
        type: 'string',
        desc: 'Time order: chronological or reverseChronological. Default is chronological.',
      },
      {
        id: 'filterExpression',
        name: 'Filter Expression',
        type: 'variable',
        desc: 'Optional Yeeflow query filter object/array/JSON from expression editor. Leave blank for all records.',
      },
      {
        id: 'height',
        name: 'Height',
        type: 'string',
        desc: 'Optional chart area height in pixels. Default is 280.',
      },
      {
        id: 'pageSize',
        name: 'Page Size',
        type: 'string',
        desc: 'Maximum records queried for aggregation. Default is 500.',
      },
      {
        id: 'dateRangeMode',
        name: 'Date Range Mode',
        type: 'string',
        desc: 'Optional local date range: all, last7Days, last30Days, last90Days, thisMonth, or last12Months. Default is all.',
      },
      {
        id: 'cumulativeMode',
        name: 'Cumulative Mode',
        type: 'variable',
        desc: 'Whether the chart shows running total values instead of per-bucket counts. Supports true/false or dynamic expression.',
      },
      {
        id: 'fillMissingBuckets',
        name: 'Fill Missing Buckets',
        type: 'variable',
        desc: 'Whether missing day/week/month buckets should be shown as zero. Supports true/false or dynamic expression.',
      },
      {
        id: 'unknownDateLabel',
        name: 'Unknown Date Label',
        type: 'string',
        desc: 'Internal label used for invalid dates in diagnostics. Invalid dates are excluded from the chart.',
      },
    ];
  }

  render(context: CodeInContext, fieldsValues: any, readonly: boolean) {
    return (
      <TrendChartModule
        context={context}
        fieldsValues={fieldsValues}
        readonly={readonly}
        params={(context && context.params) || {}}
      />
    );
  }
}

class TrendChartModule extends React.Component<any, any> {
  private mountedFlag: boolean;

  constructor(props: any) {
    super(props);
    this.mountedFlag = false;
    this.state = {
      loading: false,
      errorText: '',
      points: [],
      totalCount: 0,
      invalidDateCount: 0,
    };
  }

  componentDidMount() {
    this.mountedFlag = true;
    this.loadData();
  }

  componentDidUpdate(previousProps: any) {
    if (this.getConfigKey(previousProps.params || {}) !== this.getConfigKey(this.props.params || {})) {
      this.loadData();
    }
  }

  componentWillUnmount() {
    this.mountedFlag = false;
  }

  getConfig() {
    const params = this.props.params || {};
    return {
      dataListId: this.normalizeToString(params.dataListId),
      dateField: this.normalizeToString(params.dateField),
      titleText: this.normalizeToString(params.titleText) || 'Trend',
      subtitleText: this.normalizeToString(params.subtitleText),
      chartType: this.normalizeChartType(this.normalizeToString(params.chartType)),
      timeGranularity: this.normalizeGranularity(this.normalizeToString(params.timeGranularity)),
      maxPoints: Math.max(2, this.normalizeToNumber(params.maxPoints, 12)),
      showPointLabels: this.normalizeToBoolean(params.showPointLabels, false),
      showXAxisLabels: this.normalizeToBoolean(params.showXAxisLabels, true),
      showYAxis: this.normalizeToBoolean(params.showYAxis, true),
      emptyText: this.normalizeToString(params.emptyText) || 'No trend data available',
      colorMode: this.normalizeColorMode(this.normalizeToString(params.colorMode)),
      sortMode: this.normalizeSortMode(this.normalizeToString(params.sortMode)),
      filterExpression: this.resolveParameterValue(params.filterExpression),
      height: Math.max(200, this.normalizeToNumber(params.height, 280)),
      pageSize: Math.max(20, this.normalizeToNumber(params.pageSize, 500)),
      dateRangeMode: this.normalizeDateRangeMode(this.normalizeToString(params.dateRangeMode)),
      cumulativeMode: this.normalizeToBoolean(params.cumulativeMode, false),
      fillMissingBuckets: this.normalizeToBoolean(params.fillMissingBuckets, true),
      unknownDateLabel: this.normalizeToString(params.unknownDateLabel) || 'Invalid date',
    };
  }

  getConfigKey(params: any) {
    const config = {
      dataListId: this.normalizeToString(params.dataListId),
      dateField: this.normalizeToString(params.dateField),
      chartType: this.normalizeToString(params.chartType),
      timeGranularity: this.normalizeToString(params.timeGranularity),
      maxPoints: this.normalizeToString(params.maxPoints),
      colorMode: this.normalizeToString(params.colorMode),
      sortMode: this.normalizeToString(params.sortMode),
      filterExpression: this.normalizeToString(this.resolveParameterValue(params.filterExpression)),
      pageSize: this.normalizeToString(params.pageSize),
      dateRangeMode: this.normalizeToString(params.dateRangeMode),
      cumulativeMode: this.normalizeToString(params.cumulativeMode),
      fillMissingBuckets: this.normalizeToString(params.fillMissingBuckets),
    };
    try {
      return JSON.stringify(config);
    } catch (error) {
      return String(new Date().getTime());
    }
  }

  loadData() {
    const config = this.getConfig();
    if (!config.dataListId || !config.dateField) {
      this.setState({
        loading: false,
        errorText: '',
        points: [],
        totalCount: 0,
        invalidDateCount: 0,
      });
      return;
    }

    const client = this.props.context && this.props.context.modules && this.props.context.modules.yeeSDKClient;
    if (!client || !client.lists || typeof client.lists.queryItems !== 'function') {
      this.setState({
        loading: false,
        errorText: 'Data list service is not available',
        points: [],
        totalCount: 0,
        invalidDateCount: 0,
      });
      return;
    }

    this.setState({ loading: true, errorText: '' });
    this.queryListItems(client, config)
      .then((rows: any[]) => {
        const result = this.aggregateRows(rows, config);
        if (this.mountedFlag) {
          this.setState({
            loading: false,
            errorText: '',
            points: result.points,
            totalCount: result.totalCount,
            invalidDateCount: result.invalidDateCount,
          });
        }
      })
      .catch(() => {
        if (this.mountedFlag) {
          this.setState({
            loading: false,
            errorText: 'Unable to load trend data',
            points: [],
            totalCount: 0,
            invalidDateCount: 0,
          });
        }
      });
  }

  queryListItems(client: any, config: any): Promise<any[]> {
    const selectedFields = ['ListDataID', 'ListDataId', config.dateField];
    const parsedFilter = this.safeParseJson(config.filterExpression);
    const filterValue = parsedFilter !== null && parsedFilter !== undefined ? parsedFilter : config.filterExpression;
    const basePayload: any = {
      listId: config.dataListId,
      dataListId: config.dataListId,
      fields: selectedFields,
      fieldIds: selectedFields,
      selectedFields: selectedFields,
      pageIndex: 1,
      pageNo: 1,
      pageSize: config.pageSize,
    };

    /*
      Yeeflow list-query filter support can vary between runtimes.
      This module sends common aliases and then performs date bucketing locally.
    */
    if (filterValue !== undefined && filterValue !== null && this.normalizeToString(filterValue) !== '') {
      basePayload.filters = filterValue;
      basePayload.filter = filterValue;
      basePayload.where = filterValue;
      basePayload.filterExpression = filterValue;
    }

    const payloadWithoutListId = this.cloneObject(basePayload);
    delete payloadWithoutListId.listId;
    delete payloadWithoutListId.dataListId;

    const attempts = [
      () => client.lists.queryItems(basePayload),
      () => client.lists.queryItems(config.dataListId, payloadWithoutListId),
      () => client.lists.queryItems(config.dataListId, basePayload),
      () => client.lists.queryItems(config.dataListId, payloadWithoutListId, {}),
      () => client.lists.queryItems(config.dataListId, basePayload, {}),
    ];

    return this.tryQueryAttempts(attempts, 0);
  }

  tryQueryAttempts(attempts: any[], index: number): Promise<any[]> {
    if (index >= attempts.length) {
      return Promise.resolve([]);
    }
    let result: any;
    try {
      result = attempts[index]();
    } catch (error) {
      return this.tryQueryAttempts(attempts, index + 1);
    }
    return Promise.resolve(result)
      .then((response: any) => {
        const rows = this.extractRows(response);
        if (rows.length === 0 && index < attempts.length - 1) {
          return this.tryQueryAttempts(attempts, index + 1);
        }
        return rows;
      })
      .catch(() => this.tryQueryAttempts(attempts, index + 1));
  }

  extractRows(response: any): any[] {
    if (!response) {
      return [];
    }
    if (Array.isArray(response)) {
      return response;
    }
    const candidates = [
      response.items,
      response.Items,
      response.data,
      response.Data,
      response.rows,
      response.Rows,
      response.records,
      response.Records,
      response.list,
      response.List,
      response.result,
      response.Result,
      response.value,
      response.Value,
    ];
    for (let index = 0; index < candidates.length; index += 1) {
      if (Array.isArray(candidates[index])) {
        return candidates[index];
      }
      if (candidates[index] && typeof candidates[index] === 'object') {
        const nested = this.extractRows(candidates[index]);
        if (nested.length > 0) {
          return nested;
        }
      }
    }
    return [];
  }

  aggregateRows(rows: any[], config: any) {
    const buckets: any = {};
    const range = this.getDateRange(config.dateRangeMode);
    let invalidDateCount = 0;
    let totalCount = 0;

    for (let index = 0; index < rows.length; index += 1) {
      const rawValue = this.readItemValue(rows[index], config.dateField);
      const date = this.parseDateValue(rawValue);
      if (!date) {
        invalidDateCount += 1;
      } else if (this.isDateInRange(date, range)) {
        const bucket = this.getBucket(date, config.timeGranularity);
        if (!buckets[bucket.key]) {
          buckets[bucket.key] = {
            key: bucket.key,
            label: bucket.label,
            date: bucket.date,
            count: 0,
          };
        }
        buckets[bucket.key].count += 1;
        totalCount += 1;
      }
    }

    let rawPoints = Object.keys(buckets).map((key) => buckets[key]);
    rawPoints = this.sortBucketPoints(rawPoints, 'chronological');
    if (config.fillMissingBuckets && rawPoints.length > 0) {
      rawPoints = this.fillMissingBuckets(rawPoints, config.timeGranularity);
    }
    rawPoints = this.limitTrendPoints(rawPoints, config.maxPoints);

    let runningTotal = 0;
    let points = rawPoints.map((item) => {
      runningTotal += item.count;
      return {
        key: item.key,
        label: item.label,
        count: item.count,
        value: config.cumulativeMode ? runningTotal : item.count,
        x: 0,
        y: 0,
      };
    });
    if (config.sortMode === 'reverseChronological') {
      points = points.slice().reverse();
    }

    return {
      points: points,
      totalCount: totalCount,
      invalidDateCount: invalidDateCount,
    };
  }

  sortBucketPoints(points: any[], sortMode: string) {
    const ordered = points.slice();
    ordered.sort((a, b) => {
      const aTime = a.date ? a.date.getTime() : 0;
      const bTime = b.date ? b.date.getTime() : 0;
      return aTime - bTime;
    });
    if (sortMode === 'reverseChronological') {
      return ordered.reverse();
    }
    return ordered;
  }

  limitTrendPoints(points: any[], maxPoints: number) {
    if (points.length <= maxPoints) {
      return points;
    }
    return points.slice(points.length - maxPoints);
  }

  fillMissingBuckets(points: any[], granularity: string) {
    if (!points || points.length < 2) {
      return points || [];
    }
    const output: any[] = [];
    let cursor = this.cloneDate(points[0].date);
    const last = points[points.length - 1].date;
    const bucketMap: any = {};
    for (let index = 0; index < points.length; index += 1) {
      bucketMap[points[index].key] = points[index];
    }
    let guard = 0;
    while (cursor && last && cursor.getTime() <= last.getTime() && guard < 500) {
      const bucket = this.getBucket(cursor, granularity);
      output.push(bucketMap[bucket.key] || {
        key: bucket.key,
        label: bucket.label,
        date: bucket.date,
        count: 0,
      });
      cursor = this.addBucket(cursor, granularity);
      guard += 1;
    }
    return output;
  }

  getBucket(date: Date, granularity: string) {
    if (granularity === 'month') {
      const bucketDate = new Date(date.getFullYear(), date.getMonth(), 1);
      return {
        key: this.formatDateKey(bucketDate, 'month'),
        label: this.formatMonthLabel(bucketDate),
        date: bucketDate,
      };
    }
    if (granularity === 'week') {
      const bucketDate = this.getWeekStart(date);
      return {
        key: this.formatDateKey(bucketDate, 'day'),
        label: this.formatShortDate(bucketDate),
        date: bucketDate,
      };
    }
    const bucketDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
    return {
      key: this.formatDateKey(bucketDate, 'day'),
      label: this.formatShortDate(bucketDate),
      date: bucketDate,
    };
  }

  addBucket(date: Date, granularity: string) {
    const next = this.cloneDate(date);
    if (granularity === 'month') {
      next.setMonth(next.getMonth() + 1);
    } else if (granularity === 'week') {
      next.setDate(next.getDate() + 7);
    } else {
      next.setDate(next.getDate() + 1);
    }
    return next;
  }

  getWeekStart(date: Date) {
    const weekStart = new Date(date.getFullYear(), date.getMonth(), date.getDate());
    const day = weekStart.getDay();
    const diff = day === 0 ? -6 : 1 - day;
    weekStart.setDate(weekStart.getDate() + diff);
    return weekStart;
  }

  getDateRange(mode: string) {
    const today = new Date();
    const end = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 23, 59, 59, 999);
    let start: Date | null = null;
    if (mode === 'last7Days') {
      start = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 6);
    } else if (mode === 'last30Days') {
      start = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 29);
    } else if (mode === 'last90Days') {
      start = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 89);
    } else if (mode === 'thisMonth') {
      start = new Date(today.getFullYear(), today.getMonth(), 1);
    } else if (mode === 'last12Months') {
      start = new Date(today.getFullYear(), today.getMonth() - 11, 1);
    }
    return {
      start: start,
      end: mode === 'all' ? null : end,
    };
  }

  isDateInRange(date: Date, range: any) {
    if (!date) {
      return false;
    }
    if (range && range.start && date.getTime() < range.start.getTime()) {
      return false;
    }
    if (range && range.end && date.getTime() > range.end.getTime()) {
      return false;
    }
    return true;
  }

  readItemValue(item: any, fieldName: string): any {
    if (!item || !fieldName) {
      return undefined;
    }
    const direct = this.readObjectValueByKey(item, fieldName);
    if (direct !== undefined) {
      return this.normalizeCellValue(direct);
    }
    const containers = [
      item.values,
      item.Values,
      item.fields,
      item.Fields,
      item.data,
      item.Data,
      item.item,
      item.Item,
    ];
    for (let index = 0; index < containers.length; index += 1) {
      const value = this.readObjectValueByKey(containers[index], fieldName);
      if (value !== undefined) {
        return this.normalizeCellValue(value);
      }
    }
    const arrays = [item.fieldValues, item.FieldValues, item.fieldsValues, item.FieldsValues];
    for (let groupIndex = 0; groupIndex < arrays.length; groupIndex += 1) {
      const group = arrays[groupIndex];
      if (Array.isArray(group)) {
        for (let index = 0; index < group.length; index += 1) {
          const entry = group[index];
          const entryKey = this.normalizeToString(this.firstDefined([
            entry && entry.fieldId,
            entry && entry.FieldId,
            entry && entry.fieldID,
            entry && entry.FieldID,
            entry && entry.fieldCode,
            entry && entry.FieldCode,
            entry && entry.name,
            entry && entry.Name,
            entry && entry.key,
            entry && entry.Key,
          ]));
          if (entryKey && entryKey.toLowerCase() === fieldName.toLowerCase()) {
            return this.normalizeCellValue(this.firstDefined([
              entry.value,
              entry.Value,
              entry.fieldValue,
              entry.FieldValue,
              entry.dataValue,
              entry.DataValue,
              entry.text,
              entry.Text,
            ]));
          }
        }
      }
    }
    return undefined;
  }

  normalizeCellValue(value: any): any {
    if (value === undefined || value === null) {
      return value;
    }
    if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
      return value;
    }
    if (value instanceof Date) {
      return value;
    }
    if (Array.isArray(value)) {
      return value.length > 0 ? this.normalizeCellValue(value[0]) : undefined;
    }
    if (typeof value === 'object') {
      return this.firstDefined([
        value.value,
        value.Value,
        value.fieldValue,
        value.FieldValue,
        value.dataValue,
        value.DataValue,
        value.valueText,
        value.ValueText,
        value.text,
        value.Text,
        value.display,
        value.Display,
        value.label,
        value.Label,
        value.name,
        value.Name,
        value.title,
        value.Title,
      ]);
    }
    return value;
  }

  parseDateValue(value: any): Date | null {
    const cell = this.normalizeCellValue(value);
    if (cell === undefined || cell === null || cell === '') {
      return null;
    }
    if (cell instanceof Date && !isNaN(cell.getTime())) {
      return cell;
    }
    if (typeof cell === 'number') {
      if (cell > 100000000000) {
        const msDate = new Date(cell);
        return isNaN(msDate.getTime()) ? null : msDate;
      }
      if (cell > 1000000000) {
        const secDate = new Date(cell * 1000);
        return isNaN(secDate.getTime()) ? null : secDate;
      }
      if (cell > 20000 && cell < 80000) {
        const excelDate = new Date(Math.round((cell - 25569) * 86400 * 1000));
        return isNaN(excelDate.getTime()) ? null : excelDate;
      }
    }
    const text = this.normalizeToString(cell);
    if (!text) {
      return null;
    }
    const simpleDate = /^(\d{4})-(\d{1,2})-(\d{1,2})/.exec(text);
    if (simpleDate) {
      return new Date(Number(simpleDate[1]), Number(simpleDate[2]) - 1, Number(simpleDate[3]));
    }
    const slashDate = /^(\d{1,2})\/(\d{1,2})\/(\d{4})/.exec(text);
    if (slashDate) {
      return new Date(Number(slashDate[3]), Number(slashDate[1]) - 1, Number(slashDate[2]));
    }
    const parsed = new Date(text);
    return isNaN(parsed.getTime()) ? null : parsed;
  }

  render() {
    const config = this.getConfig();
    const points: TrendPoint[] = this.state.points || [];
    const hasConfig = Boolean(config.dataListId && config.dateField);
    const hasData = points.length > 0 && this.state.totalCount > 0;
    const valueLabel = config.cumulativeMode ? 'cumulative' : 'total';
    return (
      <div className="yf-trend-module">
        <style>{this.getStyles()}</style>
        <div className="yf-trend-header">
          <div>
            <div className="yf-trend-title">{config.titleText}</div>
            {config.subtitleText ? <div className="yf-trend-subtitle">{config.subtitleText}</div> : null}
          </div>
          {hasData ? (
            <div className="yf-trend-summary">
              <span>{this.formatNumber(this.state.totalCount)}</span>
              <em>{valueLabel}</em>
            </div>
          ) : null}
        </div>

        {!hasConfig ? (
          <div className="yf-trend-empty">Configure Data List ID and Date Field to load the trend.</div>
        ) : this.state.loading ? (
          <div className="yf-trend-loading">
            <span className="yf-trend-spinner" />
            Loading trend data
          </div>
        ) : this.state.errorText ? (
          <div className="yf-trend-empty yf-trend-error">{this.state.errorText}</div>
        ) : !hasData ? (
          <div className="yf-trend-empty">{config.emptyText}</div>
        ) : (
          <div className="yf-trend-body" style={{ minHeight: config.height + 'px' }}>
            {this.renderChart(points, config)}
            {this.state.invalidDateCount > 0 ? (
              <div className="yf-trend-note">
                {this.formatNumber(this.state.invalidDateCount)} records skipped because the date value was empty or invalid.
              </div>
            ) : null}
          </div>
        )}
      </div>
    );
  }

  renderChart(points: TrendPoint[], config: any) {
    const chartWidth = 640;
    const chartHeight = 250;
    const margin = {
      top: config.showPointLabels ? 26 : 16,
      right: 18,
      bottom: config.showXAxisLabels ? 46 : 18,
      left: config.showYAxis ? 48 : 18,
    };
    const plotWidth = chartWidth - margin.left - margin.right;
    const plotHeight = chartHeight - margin.top - margin.bottom;
    const maxValue = Math.max(1, Math.max.apply(null, points.map((point) => point.value)));
    const color = this.getPalette(config.colorMode);
    const plotted = points.map((point, index) => {
      const denominator = points.length > 1 ? points.length - 1 : 1;
      const x = margin.left + (plotWidth * index) / denominator;
      const y = margin.top + plotHeight - (point.value / maxValue) * plotHeight;
      return {
        key: point.key,
        label: point.label,
        count: point.count,
        value: point.value,
        x: x,
        y: y,
      };
    });
    const linePath = this.buildLinePath(plotted);
    const areaPath = this.buildAreaPath(plotted, margin.top + plotHeight);

    return (
      <div className="yf-trend-chart-wrap">
        <svg className="yf-trend-chart" viewBox={'0 0 ' + chartWidth + ' ' + chartHeight} preserveAspectRatio="none" role="img">
          {config.showYAxis ? this.renderGrid(chartWidth, margin, plotHeight, maxValue) : null}
          {config.chartType === 'column' ? this.renderColumns(plotted, margin, plotWidth, plotHeight, color, config) : null}
          {config.chartType === 'area' ? <path className="yf-trend-area" d={areaPath} fill={color.area} /> : null}
          {config.chartType !== 'column' ? <path className="yf-trend-line" d={linePath} stroke={color.main} /> : null}
          {config.chartType !== 'column' ? this.renderPoints(plotted, color, config) : null}
          {config.showXAxisLabels ? this.renderXAxisLabels(plotted, chartHeight) : null}
        </svg>
      </div>
    );
  }

  renderGrid(chartWidth: number, margin: any, plotHeight: number, maxValue: number) {
    const ticks = [0, 0.5, 1];
    return (
      <g>
        {ticks.map((tick, index) => {
          const y = margin.top + plotHeight - plotHeight * tick;
          const value = Math.round(maxValue * tick);
          return (
            <g key={'grid-' + index}>
              <line className="yf-trend-grid" x1={margin.left} x2={chartWidth - margin.right} y1={y} y2={y} />
              <text className="yf-trend-y-label" x={margin.left - 10} y={y + 4}>{this.formatNumber(value)}</text>
            </g>
          );
        })}
      </g>
    );
  }

  renderColumns(points: TrendPoint[], margin: any, plotWidth: number, plotHeight: number, color: any, config: any) {
    const columnGap = points.length > 8 ? 5 : 10;
    const columnWidth = Math.max(8, Math.min(42, plotWidth / Math.max(points.length, 1) - columnGap));
    const baseline = margin.top + plotHeight;
    return (
      <g>
        {points.map((point, index) => {
          const barHeight = Math.max(2, baseline - point.y);
          return (
            <g key={'column-' + point.key + index}>
              <rect
                className="yf-trend-column"
                x={point.x - columnWidth / 2}
                y={point.y}
                width={columnWidth}
                height={barHeight}
                rx="5"
                fill={color.main}
              />
              {config.showPointLabels ? (
                <text className="yf-trend-point-label" x={point.x} y={point.y - 8}>{this.formatNumber(point.value)}</text>
              ) : null}
            </g>
          );
        })}
      </g>
    );
  }

  renderPoints(points: TrendPoint[], color: any, config: any) {
    return (
      <g>
        {points.map((point, index) => (
          <g key={'point-' + point.key + index}>
            <circle className="yf-trend-point-ring" cx={point.x} cy={point.y} r="5" fill="#fff" stroke={color.main} />
            <circle className="yf-trend-point" cx={point.x} cy={point.y} r="2.5" fill={color.main} />
            {config.showPointLabels ? (
              <text className="yf-trend-point-label" x={point.x} y={point.y - 10}>{this.formatNumber(point.value)}</text>
            ) : null}
          </g>
        ))}
      </g>
    );
  }

  renderXAxisLabels(points: TrendPoint[], chartHeight: number) {
    const step = Math.max(1, Math.ceil(points.length / 8));
    return (
      <g>
        {points.map((point, index) => {
          if (index % step !== 0 && index !== points.length - 1) {
            return null;
          }
          return (
            <text className="yf-trend-x-label" key={'x-' + point.key + index} x={point.x} y={chartHeight - 14}>
              {point.label}
            </text>
          );
        })}
      </g>
    );
  }

  buildLinePath(points: TrendPoint[]) {
    if (!points.length) {
      return '';
    }
    return points.map((point, index) => (index === 0 ? 'M ' : 'L ') + point.x + ' ' + point.y).join(' ');
  }

  buildAreaPath(points: TrendPoint[], baseline: number) {
    if (!points.length) {
      return '';
    }
    const line = this.buildLinePath(points);
    const last = points[points.length - 1];
    const first = points[0];
    return line + ' L ' + last.x + ' ' + baseline + ' L ' + first.x + ' ' + baseline + ' Z';
  }

  normalizeChartType(value: string) {
    const text = (value || '').toLowerCase();
    if (text === 'area' || text === 'arealine' || text === 'area-line') {
      return 'area';
    }
    if (text === 'column' || text === 'bar' || text === 'verticalbar' || text === 'vertical-bar') {
      return 'column';
    }
    return 'line';
  }

  normalizeGranularity(value: string) {
    const text = (value || '').toLowerCase();
    if (text === 'week' || text === 'weekly') {
      return 'week';
    }
    if (text === 'month' || text === 'monthly') {
      return 'month';
    }
    return 'day';
  }

  normalizeSortMode(value: string) {
    const text = (value || '').toLowerCase();
    if (text === 'reversechronological' || text === 'reverse-chronological' || text === 'newestfirst' || text === 'desc') {
      return 'reverseChronological';
    }
    return 'chronological';
  }

  normalizeDateRangeMode(value: string) {
    const text = (value || '').toLowerCase();
    if (text === 'last7days' || text === 'last-7-days' || text === '7') {
      return 'last7Days';
    }
    if (text === 'last30days' || text === 'last-30-days' || text === '30') {
      return 'last30Days';
    }
    if (text === 'last90days' || text === 'last-90-days' || text === '90') {
      return 'last90Days';
    }
    if (text === 'thismonth' || text === 'this-month') {
      return 'thisMonth';
    }
    if (text === 'last12months' || text === 'last-12-months' || text === '12months') {
      return 'last12Months';
    }
    return 'all';
  }

  normalizeColorMode(value: string) {
    const text = (value || '').toLowerCase();
    if (text === 'green' || text === 'amber' || text === 'red' || text === 'violet' || text === 'slate') {
      return text;
    }
    return 'yeeflow';
  }

  getPalette(colorMode: string) {
    if (colorMode === 'green') {
      return { main: '#12B76A', area: 'rgba(18,183,106,.14)' };
    }
    if (colorMode === 'amber') {
      return { main: '#F59E0B', area: 'rgba(245,158,11,.16)' };
    }
    if (colorMode === 'red') {
      return { main: '#E45665', area: 'rgba(228,86,101,.14)' };
    }
    if (colorMode === 'violet') {
      return { main: '#7A5AF8', area: 'rgba(122,90,248,.14)' };
    }
    if (colorMode === 'slate') {
      return { main: '#315A8C', area: 'rgba(49,90,140,.14)' };
    }
    return { main: '#146FF6', area: 'rgba(20,111,246,.14)' };
  }

  resolveParameterValue(value: any): any {
    if (value === undefined || value === null) {
      return '';
    }
    if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || Array.isArray(value)) {
      return value;
    }
    if (typeof value === 'object') {
      const preferred = this.firstDefined([
        value.value,
        value.Value,
        value.data,
        value.Data,
        value.result,
        value.Result,
        value.key,
        value.Key,
        value.id,
        value.Id,
        value.fieldId,
        value.FieldId,
        value.fieldName,
        value.FieldName,
        value.label,
        value.Label,
        value.name,
        value.Name,
      ]);
      if (preferred !== undefined) {
        return this.resolveParameterValue(preferred);
      }
    }
    return value;
  }

  safeParseJson(value: any): any {
    if (Array.isArray(value) || (value && typeof value === 'object')) {
      return value;
    }
    const text = this.normalizeToString(value);
    if (!text) {
      return null;
    }
    try {
      return JSON.parse(text);
    } catch (error) {
      return null;
    }
  }

  normalizeToString(value: any): string {
    const resolved = this.resolveParameterValue(value);
    if (resolved === undefined || resolved === null) {
      return '';
    }
    if (typeof resolved === 'string' || typeof resolved === 'number' || typeof resolved === 'boolean') {
      return String(resolved).trim();
    }
    if (Array.isArray(resolved)) {
      return resolved.map((item) => this.normalizeToString(item)).filter(Boolean).join(', ');
    }
    if (typeof resolved === 'object') {
      const preferred = this.firstDefined([
        resolved.value,
        resolved.Value,
        resolved.text,
        resolved.Text,
        resolved.display,
        resolved.Display,
        resolved.label,
        resolved.Label,
        resolved.name,
        resolved.Name,
        resolved.title,
        resolved.Title,
        resolved.key,
        resolved.Key,
      ]);
      if (preferred !== undefined) {
        return this.normalizeToString(preferred);
      }
      try {
        return JSON.stringify(resolved);
      } catch (error) {
        return '';
      }
    }
    return '';
  }

  normalizeToBoolean(value: any, fallback: boolean): boolean {
    const resolved = this.resolveParameterValue(value);
    if (resolved === undefined || resolved === null || resolved === '') {
      return fallback;
    }
    if (typeof resolved === 'boolean') {
      return resolved;
    }
    if (typeof resolved === 'number') {
      return resolved !== 0;
    }
    const text = this.normalizeToString(resolved).toLowerCase();
    if (text === 'true' || text === '1' || text === 'yes' || text === 'y' || text === 'on') {
      return true;
    }
    if (text === 'false' || text === '0' || text === 'no' || text === 'n' || text === 'off') {
      return false;
    }
    return fallback;
  }

  normalizeToNumber(value: any, fallback: number): number {
    const text = this.normalizeToString(value);
    if (!text) {
      return fallback;
    }
    const parsed = Number(text.replace(/,/g, ''));
    return isNaN(parsed) ? fallback : parsed;
  }

  readObjectValueByKey(source: any, wantedKey: string): any {
    if (!source || typeof source !== 'object' || Array.isArray(source) || !wantedKey) {
      return undefined;
    }
    if (source[wantedKey] !== undefined) {
      return source[wantedKey];
    }
    const normalizedWanted = wantedKey.toLowerCase();
    const keys = Object.keys(source);
    for (let index = 0; index < keys.length; index += 1) {
      if (keys[index].toLowerCase() === normalizedWanted) {
        return source[keys[index]];
      }
    }
    return undefined;
  }

  firstDefined(values: any[]) {
    for (let index = 0; index < values.length; index += 1) {
      if (values[index] !== undefined && values[index] !== null) {
        return values[index];
      }
    }
    return undefined;
  }

  cloneObject(value: any) {
    const output: any = {};
    const keys = Object.keys(value || {});
    for (let index = 0; index < keys.length; index += 1) {
      output[keys[index]] = value[keys[index]];
    }
    return output;
  }

  cloneDate(value: Date) {
    return new Date(value.getFullYear(), value.getMonth(), value.getDate());
  }

  formatDateKey(date: Date, mode: string) {
    const year = date.getFullYear();
    const month = this.pad2(date.getMonth() + 1);
    const day = this.pad2(date.getDate());
    if (mode === 'month') {
      return year + '-' + month;
    }
    return year + '-' + month + '-' + day;
  }

  formatShortDate(date: Date) {
    return this.monthName(date.getMonth()) + ' ' + date.getDate();
  }

  formatMonthLabel(date: Date) {
    return this.monthName(date.getMonth()) + ' ' + date.getFullYear();
  }

  monthName(index: number) {
    return ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][index] || '';
  }

  pad2(value: number) {
    return value < 10 ? '0' + value : String(value);
  }

  formatNumber(value: number) {
    try {
      return Number(value || 0).toLocaleString();
    } catch (error) {
      return String(value || 0);
    }
  }

  getStyles() {
    return [
      '.yf-trend-module{box-sizing:border-box;width:100%;min-width:0;background:#fff;border:1px solid #e5eaf3;border-radius:8px;padding:18px 18px 16px;color:#111827;font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;box-shadow:0 1px 2px rgba(16,24,40,.04);}',
      '.yf-trend-module *{box-sizing:border-box;}',
      '.yf-trend-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:16px;}',
      '.yf-trend-title{font-size:16px;line-height:22px;font-weight:700;color:#101828;}',
      '.yf-trend-subtitle{margin-top:3px;font-size:12px;line-height:18px;color:#667085;}',
      '.yf-trend-summary{flex:0 0 auto;display:flex;align-items:baseline;gap:5px;background:#f8fafc;border:1px solid #e5eaf3;border-radius:999px;padding:4px 10px;color:#475467;}',
      '.yf-trend-summary span{font-size:12px;line-height:18px;font-weight:700;color:#101828;font-variant-numeric:tabular-nums;}',
      '.yf-trend-summary em{font-size:12px;line-height:18px;font-style:normal;color:#667085;}',
      '.yf-trend-body{display:flex;flex-direction:column;gap:8px;min-width:0;}',
      '.yf-trend-chart-wrap{width:100%;min-height:220px;}',
      '.yf-trend-chart{display:block;width:100%;height:100%;min-height:220px;overflow:visible;}',
      '.yf-trend-grid{stroke:#edf1f7;stroke-width:1;}',
      '.yf-trend-y-label{fill:#98a2b3;font-size:11px;text-anchor:end;font-variant-numeric:tabular-nums;}',
      '.yf-trend-line{fill:none;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;}',
      '.yf-trend-area{stroke:none;}',
      '.yf-trend-point-ring{stroke-width:2;}',
      '.yf-trend-point-label{fill:#344054;font-size:11px;font-weight:650;text-anchor:middle;font-variant-numeric:tabular-nums;}',
      '.yf-trend-x-label{fill:#667085;font-size:11px;text-anchor:middle;}',
      '.yf-trend-column{opacity:.92;}',
      '.yf-trend-note{font-size:12px;line-height:18px;color:#667085;text-align:center;}',
      '.yf-trend-empty,.yf-trend-loading{min-height:200px;display:flex;align-items:center;justify-content:center;text-align:center;border:1px dashed #d8deea;border-radius:8px;background:#f8fafc;color:#667085;font-size:13px;line-height:20px;padding:24px;}',
      '.yf-trend-error{color:#b42318;background:#fff7f6;border-color:#fecdca;}',
      '.yf-trend-loading{gap:10px;}',
      '.yf-trend-spinner{width:16px;height:16px;border-radius:999px;border:2px solid #d8deea;border-top-color:#146ff6;animation:yfTrendSpin 900ms linear infinite;}',
      '@keyframes yfTrendSpin{to{transform:rotate(360deg);}}',
      '@media(max-width:640px){.yf-trend-module{padding:16px;}.yf-trend-header{flex-direction:column;align-items:flex-start;}.yf-trend-body{min-height:0!important;}.yf-trend-chart-wrap{overflow-x:auto;}.yf-trend-chart{min-width:520px;}.yf-trend-summary{align-self:flex-start;}}',
    ].join('');
  }
}

User guide

Download guide

Trend Chart Module

Short Description

Trend Chart Module is a reusable Yeeflow dashboard custom code template that queries records from a data list, groups them by date, and displays the result as a line, area, or column trend chart.

Purpose / What This Is For

Use this control when a dashboard needs to show how operational volume changes over time. It helps customers answer questions such as:

  • How many requests were submitted each day?
  • How has approval volume changed this month?
  • Are inbound and outbound records increasing or decreasing?
  • How many tasks were completed each week?
  • What is the monthly issue/case volume trend?

Instead of creating a one-off chart for each dashboard, teams can configure this reusable module with a Yeeflow data list and a date field.

Supported Placement

This template is optimized for:

  • Dashboard page

It is a display/analytics module and does not write values back to form fields or dashboard variables.

When To Use This Template

Use Trend Chart Module when:

  • You need to count records over time.
  • The source data is stored in a Yeeflow data list.
  • The list has a reliable date field such as created date, submitted date, completed date, or attendance date.
  • Users need a line, area, or column chart on a dashboard.
  • You want reusable configuration across multiple customer apps.
Recommended Defaults

For most dashboard modules, start with:

ParameterRecommended Value
chartTypeline
timeGranularityday
maxPoints12
showPointLabelsfalse
showXAxisLabelstrue
showYAxistrue
colorModeyeeflow
sortModechronological
dateRangeModelast30Days
cumulativeModefalse
fillMissingBucketstrue
height280
pageSize500
Input Parameters Overview
ParameterTypeRequiredPurposeExample Value
dataListIdExpression editorYesTarget Yeeflow data list used as the data source.2034150397060198400
dateFieldExpression editorYesField ID/name used as the time axis.CreatedTime
titleTextPlain textNoTitle shown at the top of the module.Request Trend
subtitleTextPlain textNoSupporting text below the title.Daily submitted requests
chartTypePlain textNoChart presentation mode.line
timeGranularityPlain textNoTime bucket grouping.day
maxPointsNumeric configNoMaximum number of time buckets shown.12
showPointLabelsExpression editorNoWhether value labels are shown on chart points/columns.false
showXAxisLabelsExpression editorNoWhether date labels are shown on the x-axis.true
showYAxisExpression editorNoWhether y-axis labels and grid lines are shown.true
emptyTextPlain textNoText shown when no valid dated records are available.No trend data found
colorModePlain textNoChart color tone.yeeflow
sortModePlain textNoTime sorting behavior.chronological
filterExpressionExpression editorNoOptional Yeeflow query filter object, array, or JSON value.[{"field":"Status","operator":"neq","value":"Cancelled"}]
heightNumeric configNoMinimum chart area height in pixels.280
pageSizeNumeric configNoMaximum records queried for aggregation.500
dateRangeModePlain textNoLocal date range applied after query.last30Days
cumulativeModeExpression editorNoWhether the chart shows running totals.false
fillMissingBucketsExpression editorNoWhether missing date buckets are shown as zero.true
unknownDateLabelPlain textNoInternal label for invalid dates in diagnostics.Invalid date
Detailed Parameter Explanation
dataListId

The Yeeflow data list ID to query. This should point to the list that contains the records you want to trend.

This parameter uses the expression editor so it can be configured from a static value, variable, temp variable, or expression result.

dateField

The field ID or field name used for the time axis. The field should contain a valid date or date/time value.

Examples:

  • CreatedTime
  • SubmitDate
  • CompletedDate
  • AttendanceDate
  • RequestDate

The code reads common Yeeflow row shapes defensively, including direct fields, values, Fields, and array-style field values.

titleText

Optional title displayed at the top of the module.

subtitleText

Optional helper text displayed below the title. Use it to clarify the date range or data scope.

chartType

Controls the chart presentation.

Supported values:

  • line
  • area
  • areaLine
  • column
  • bar

If blank or invalid, the module uses line.

timeGranularity

Controls how records are grouped by time.

Supported values:

  • day
  • week
  • month

Daily aggregation is the default. Weekly grouping uses Monday as the start of the week.

maxPoints

Limits the number of time buckets displayed. If the chart has more buckets than the limit, the latest buckets are shown.

Default: 12

showPointLabels

Controls whether numeric labels appear above line points or columns.

This parameter uses the expression editor and supports values such as true, false, 1, 0, yes, and no.

showXAxisLabels

Controls whether date bucket labels appear along the x-axis.

showYAxis

Controls whether the y-axis labels and light grid lines are displayed.

emptyText

Text shown when the query succeeds but no valid dated records are available.

colorMode

Controls the chart tone.

Supported values:

  • yeeflow
  • green
  • amber
  • red
  • violet
  • slate

Default: yeeflow

sortMode

Controls time ordering.

Supported values:

  • chronological
  • reverseChronological

Default: chronological

filterExpression

Optional filter passed to the list query. This can be a JSON string, object, array, variable, or temp variable from the expression editor.

The template passes common filter aliases (filters, filter, where, and filterExpression) because exact Yeeflow query filter support may differ by runtime. If the filter shape is not supported by the current tenant/runtime, leave this parameter blank or adjust the filter format to match that environment.

height

Minimum chart area height in pixels.

Default: 280

pageSize

Maximum number of records queried for aggregation. Increase it for larger lists, but keep dashboard performance in mind.

Default: 500

dateRangeMode

Applies a local date range after records are returned by the query.

Supported values:

  • all
  • last7Days
  • last30Days
  • last90Days
  • thisMonth
  • last12Months

Default: all

cumulativeMode

When enabled, each point shows a running total instead of the count for that individual bucket.

This is useful for showing year-to-date volume, cumulative completions, or growing backlog count when the source data represents created records.

fillMissingBuckets

When enabled, missing date buckets between the first and last bucket are shown as zero. This helps trend charts avoid visual gaps.

unknownDateLabel

Used internally for invalid date handling. Invalid or empty dates are excluded from the chart and counted in the skipped-record note.

Step-By-Step Setup Guide
  1. Add a Custom Code control to a Yeeflow dashboard page.
  2. Open the code editor and paste the trend-chart-module.tsx code.
  3. Set dataListId to the target data list ID.
  4. Set dateField to the field ID/name that contains the record date.
  5. Add a clear titleText, such as Request Trend.
  6. Choose chartType, such as line, area, or column.
  7. Choose timeGranularity, such as day, week, or month.
  8. Configure dateRangeMode, maxPoints, and pageSize.
  9. Optionally configure filterExpression to limit which records are included.
  10. Preview the dashboard and confirm the chart matches the expected list data.
  11. Publish the dashboard when the result is correct.
Result / Expected Output

After configuration, users see a dashboard card with:

  • title and optional subtitle
  • total record count
  • line, area, or column trend chart
  • optional point labels
  • optional x-axis labels and y-axis grid
  • empty/loading/error states when appropriate
  • note when records are skipped because their date is empty or invalid

The template reads records from the configured data list and displays aggregated counts by date bucket. It does not save values back to fields or variables.

Real Business Examples
Daily Request Trend

Group service request records by CreatedTime using day granularity to show daily request volume.

Monthly Approval Volume

Group approval records by SubmitDate using month granularity and column chart type.

Weekly Task Completion Trend

Group completed task records by CompletedDate using week granularity and area chart type.

Attendance Trend

Group attendance records by AttendanceDate to show daily check-in volume for operations dashboards.

Notes / Assumptions / Limitations
  • The template is optimized for dashboard display and does not write output values.
  • The query uses yeeSDKClient.lists.queryItems(...).
  • The code sends common query payload aliases for compatibility, but exact filter support may vary by Yeeflow runtime.
  • Aggregation is performed in the custom code after records are returned.
  • Very large lists should use a reasonable pageSize, a dashboard-specific filtered list, or a server-side filtered query for performance.
  • Empty or invalid dates are excluded from the trend and shown as skipped records.
  • Weekly buckets start on Monday.
  • dateRangeMode is applied locally after query results are returned.
Testing Checklist
  • Confirm the module renders on a dashboard page.
  • Confirm dataListId loads the correct data list.
  • Confirm dateField reads the correct date value.
  • Test line, area, and column.
  • Test day, week, and month.
  • Test showPointLabels = true and false.
  • Test showXAxisLabels = true and false.
  • Test showYAxis = true and false.
  • Test dateRangeMode, especially last30Days and thisMonth.
  • Test cumulativeMode = true.
  • Test fillMissingBuckets = true.
  • Test empty data state.
  • Test missing configuration state.
  • Test invalid or empty date values.
  • Test mobile or narrow dashboard widths.
Troubleshooting
The chart says to configure Data List ID and Date Field

Check that both dataListId and dateField are configured. If using expression editor values, confirm the expression returns a real list ID and field ID/name.

The chart shows no data

Confirm the data list contains records and the selected date field has valid values. Also check whether filterExpression or dateRangeMode is excluding all records.

Counts do not match expectations

The template counts records returned by queryItems. Check pageSize, filters, date range, and whether the list has more records than the query limit.

Date labels look wrong

Confirm that dateField is the correct Yeeflow field ID/name. If the field returns text, use a standard date format such as YYYY-MM-DD or a Yeeflow date/date-time field.

Filter does not work

Filter support can vary by Yeeflow runtime. Try leaving filterExpression blank first. Then test the filter shape expected by your specific data list query environment.

Example configuration

Download config

Trend Chart Module Example Config

Example 1: Daily Request Trend
ParameterExample Value
dataListId2034150397060198400
dateFieldCreatedTime
titleTextDaily Request Trend
subtitleTextSubmitted requests in the last 30 days
chartTypeline
timeGranularityday
maxPoints14
showPointLabelsfalse
showXAxisLabelstrue
showYAxistrue
emptyTextNo request records found
colorModeyeeflow
sortModechronological
dateRangeModelast30Days
cumulativeModefalse
fillMissingBucketstrue
height280
pageSize500
Example 2: Monthly Approval Volume
ParameterExample Value
dataListId2034150397060198400
dateFieldSubmitDate
titleTextMonthly Approval Volume
subtitleTextApproval submissions by month
chartTypecolumn
timeGranularitymonth
maxPoints12
showPointLabelstrue
showXAxisLabelstrue
showYAxistrue
colorModeslate
dateRangeModelast12Months
cumulativeModefalse
fillMissingBucketstrue
height300
Optional Filter Expression Example

Use this only if your Yeeflow runtime supports this filter shape:

[
  {
    "field": "Status",
    "operator": "neq",
    "value": "Cancelled"
  }
]

If the chart returns no data after adding a filter, test again with filterExpression blank to confirm the data list and date field are configured correctly.