Overview
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.
Developer reference
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.
Source preview
Code preview
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('');
}
}Implementation notes
User 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:
| Parameter | Recommended Value |
| --- | --- |
| `chartType` | `line` |
| `timeGranularity` | `day` |
| `maxPoints` | `12` |
| `showPointLabels` | `false` |
| `showXAxisLabels` | `true` |
| `showYAxis` | `true` |
| `colorMode` | `yeeflow` |
| `sortMode` | `chronological` |
| `dateRangeMode` | `last30Days` |
| `cumulativeMode` | `false` |
| `fillMissingBuckets` | `true` |
| `height` | `280` |
| `pageSize` | `500` |
## Input Parameters Overview
| Parameter | Type | Required | Purpose | Example Value |
| --- | --- | --- | --- | --- |
| `dataListId` | Expression editor | Yes | Target Yeeflow data list used as the data source. | `2034150397060198400` |
| `dateField` | Expression editor | Yes | Field ID/name used as the time axis. | `CreatedTime` |
| `titleText` | Plain text | No | Title shown at the top of the module. | `Request Trend` |
| `subtitleText` | Plain text | No | Supporting text below the title. | `Daily submitted requests` |
| `chartType` | Plain text | No | Chart presentation mode. | `line` |
| `timeGranularity` | Plain text | No | Time bucket grouping. | `day` |
| `maxPoints` | Numeric config | No | Maximum number of time buckets shown. | `12` |
| `showPointLabels` | Expression editor | No | Whether value labels are shown on chart points/columns. | `false` |
| `showXAxisLabels` | Expression editor | No | Whether date labels are shown on the x-axis. | `true` |
| `showYAxis` | Expression editor | No | Whether y-axis labels and grid lines are shown. | `true` |
| `emptyText` | Plain text | No | Text shown when no valid dated records are available. | `No trend data found` |
| `colorMode` | Plain text | No | Chart color tone. | `yeeflow` |
| `sortMode` | Plain text | No | Time sorting behavior. | `chronological` |
| `filterExpression` | Expression editor | No | Optional Yeeflow query filter object, array, or JSON value. | `[{"field":"Status","operator":"neq","value":"Cancelled"}]` |
| `height` | Numeric config | No | Minimum chart area height in pixels. | `280` |
| `pageSize` | Numeric config | No | Maximum records queried for aggregation. | `500` |
| `dateRangeMode` | Plain text | No | Local date range applied after query. | `last30Days` |
| `cumulativeMode` | Expression editor | No | Whether the chart shows running totals. | `false` |
| `fillMissingBuckets` | Expression editor | No | Whether missing date buckets are shown as zero. | `true` |
| `unknownDateLabel` | Plain text | No | Internal 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.Configuration
Example configuration
# Trend Chart Module Example Config
## Example 1: Daily Request Trend
| Parameter | Example Value |
| --- | --- |
| `dataListId` | `2034150397060198400` |
| `dateField` | `CreatedTime` |
| `titleText` | `Daily Request Trend` |
| `subtitleText` | `Submitted requests in the last 30 days` |
| `chartType` | `line` |
| `timeGranularity` | `day` |
| `maxPoints` | `14` |
| `showPointLabels` | `false` |
| `showXAxisLabels` | `true` |
| `showYAxis` | `true` |
| `emptyText` | `No request records found` |
| `colorMode` | `yeeflow` |
| `sortMode` | `chronological` |
| `dateRangeMode` | `last30Days` |
| `cumulativeMode` | `false` |
| `fillMissingBuckets` | `true` |
| `height` | `280` |
| `pageSize` | `500` |
## Example 2: Monthly Approval Volume
| Parameter | Example Value |
| --- | --- |
| `dataListId` | `2034150397060198400` |
| `dateField` | `SubmitDate` |
| `titleText` | `Monthly Approval Volume` |
| `subtitleText` | `Approval submissions by month` |
| `chartType` | `column` |
| `timeGranularity` | `month` |
| `maxPoints` | `12` |
| `showPointLabels` | `true` |
| `showXAxisLabels` | `true` |
| `showYAxis` | `true` |
| `colorMode` | `slate` |
| `dateRangeMode` | `last12Months` |
| `cumulativeMode` | `false` |
| `fillMissingBuckets` | `true` |
| `height` | `300` |
## Optional Filter Expression Example
Use this only if your Yeeflow runtime supports this filter shape:
```json
[
{
"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.




