first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,19 @@
{
"node": "n8n-nodes-base.timeSaved",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"details": "Track Saved Time",
"categories": ["Core Nodes", "Development"],
"resources": {
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.savedTime/"
}
]
},
"alias": ["time", "track", "saved"],
"subcategories": {
"Core Nodes": ["Helpers"]
}
}
@@ -0,0 +1,111 @@
import type {
IExecuteFunctions,
INodeExecutionData,
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
import { assertParamIsNumber, NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
export class TimeSaved implements INodeType {
description: INodeTypeDescription = {
displayName: 'Track Time Saved',
name: 'timeSaved',
icon: 'fa:timer',
group: ['organization'],
version: 1,
description:
'Dynamically track time saved based on the workflows execution path and the number of items processed',
defaults: {
name: 'Time Saved',
color: '#1E90FF',
},
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
properties: [
{
displayName:
'For each run, time saved is the sum of all Time Saved nodes that execute. Use this when different execution paths or items save different amounts of time.',
name: 'notice',
type: 'notice',
default: '',
},
{
displayName: 'Calculation Mode',
name: 'mode',
type: 'options',
default: 'once',
noDataExpression: true,
options: [
{
name: 'Once For All Items',
value: 'once',
description: 'Counts minutes saved once for all input items',
},
{
name: 'Per Item',
value: 'perItem',
description: 'Multiply minutes saved by the number of input items',
},
],
},
{
displayName: 'Minutes Saved',
name: 'minutesSaved',
type: 'number',
default: 0,
noDataExpression: true,
typeOptions: {
minValue: 0,
},
description: 'Number of minutes saved by this workflow execution',
},
],
hints: [
{
type: 'info',
message:
'Multiple Saved Time nodes in the same workflow will have their values summed together.',
displayCondition: '=true',
whenToDisplay: 'beforeExecution',
location: 'outputPane',
},
],
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const mode = this.getNodeParameter('mode', 0) as 'fixed' | 'perItem' | 'expression';
let timeSavedMinutes = this.getNodeParameter('minutesSaved', 0);
assertParamIsNumber('minutesSaved', timeSavedMinutes, this.getNode());
try {
if (mode === 'perItem') {
timeSavedMinutes = items.length * timeSavedMinutes;
}
// Ensure non-negative
if (timeSavedMinutes < 0) {
throw new NodeOperationError(
this.getNode(),
`Time saved cannot be negative, got: ${timeSavedMinutes}`,
);
}
// Set metadata using the clean API
this.setMetadata({
timeSaved: {
minutes: timeSavedMinutes,
},
});
// Pass through all items unchanged
return [items];
} catch (error) {
if (this.continueOnFail()) {
return [[{ json: { error: error.message } }]];
}
throw error;
}
}
}
@@ -0,0 +1,122 @@
import type { IExecuteFunctions } from 'n8n-workflow';
import { TimeSaved } from '../TimeSaved.node';
describe('TimeSaved node', () => {
it('should be defined', () => {
expect(TimeSaved).toBeDefined();
});
it('should set metadata with time saved for fixed option', async () => {
const node = new TimeSaved();
const executionFunctions = {
getInputData: jest.fn().mockReturnValue([{ json: {} }]),
getNodeParameter: jest.fn().mockReturnValueOnce('fixed').mockReturnValueOnce(10),
continueOnFail: jest.fn().mockReturnValue(false),
setMetadata: jest.fn(),
getNode: jest.fn().mockReturnValue({
name: 'TimeSaved',
type: 'timeSaved',
}),
} as any as IExecuteFunctions;
const result = await node.execute.call(executionFunctions);
expect(executionFunctions.setMetadata).toHaveBeenCalledWith({
timeSaved: {
minutes: 10,
},
});
expect(result[0].length).toEqual(1);
});
it('should set metadata with time saved for per item option', async () => {
const node = new TimeSaved();
const executionFunctions = {
getInputData: jest.fn().mockReturnValueOnce([{ json: {} }, { json: {} }]),
getNodeParameter: jest.fn().mockReturnValueOnce('perItem').mockReturnValueOnce(10),
continueOnFail: jest.fn().mockReturnValue(false),
setMetadata: jest.fn(),
getNode: jest.fn().mockReturnValue({
name: 'TimeSaved',
type: 'timeSaved',
}),
} as any as IExecuteFunctions;
const result = await node.execute.call(executionFunctions);
expect(executionFunctions.setMetadata).toHaveBeenCalledWith({
timeSaved: {
minutes: 20,
},
});
expect(result[0].length).toEqual(2);
});
it('should return an error if the minutes saved is negative', async () => {
const node = new TimeSaved();
const executionFunctions = {
getInputData: jest.fn().mockReturnValueOnce([{ json: {} }]),
getNodeParameter: jest.fn().mockReturnValueOnce('fixed').mockReturnValueOnce(-1),
continueOnFail: jest.fn().mockReturnValue(false),
setMetadata: jest.fn(),
getNode: jest.fn().mockReturnValue({
name: 'TimeSaved',
type: 'timeSaved',
}),
} as any as IExecuteFunctions;
await expect(node.execute.call(executionFunctions)).rejects.toThrow(
'Time saved cannot be negative, got: -1',
);
expect(executionFunctions.setMetadata).not.toHaveBeenCalled();
});
it('should return an error if the minutes saved is not a number', async () => {
const node = new TimeSaved();
const executionFunctions = {
getInputData: jest.fn().mockReturnValueOnce([{ json: {} }]),
getNodeParameter: jest.fn().mockReturnValueOnce('fixed').mockReturnValueOnce('not a number'),
continueOnFail: jest.fn().mockReturnValue(false),
setMetadata: jest.fn(),
getNode: jest.fn().mockReturnValue({
name: 'TimeSaved',
type: 'timeSaved',
}),
} as any as IExecuteFunctions;
await expect(node.execute.call(executionFunctions)).rejects.toThrow(
'Parameter "minutesSaved" is not number',
);
expect(executionFunctions.setMetadata).not.toHaveBeenCalled();
});
it('should continue on fail if the minutes saved is not a number and the config is set to continue on fail', async () => {
const node = new TimeSaved();
const executionFunctions = {
getInputData: jest.fn().mockReturnValueOnce([{ json: {} }]),
getNodeParameter: jest.fn().mockReturnValueOnce('fixed').mockReturnValueOnce(10),
continueOnFail: jest.fn().mockReturnValue(true),
setMetadata: jest.fn().mockImplementationOnce(() => {
throw new Error('Test error');
}),
getNode: jest.fn().mockReturnValue({
name: 'TimeSaved',
type: 'timeSaved',
}),
} as any as IExecuteFunctions;
const result = await node.execute.call(executionFunctions);
expect(result[0].length).toEqual(1);
expect(executionFunctions.continueOnFail).toHaveBeenCalled();
});
});