feat(automation): expose group title and allow setting secondary text

Surface {{groupNow.*}} template variables (title, note, colour, times,
custom fields) in the automation template autocomplete so events inside a
group can reference their group. The runtime store already carries
groupNow, so substitution and filters worked already; this makes it
discoverable.

Extend the message-secondary action with an optional text field so an
automation can set the secondary message content, not just its source.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZYPZVdZLWowU7DyzGkWyy
This commit is contained in:
Claude
2026-07-08 15:39:48 +00:00
committed by Carlos Valente
parent 25f0dba83a
commit 86b2132cdc
6 changed files with 102 additions and 26 deletions
@@ -120,6 +120,12 @@ export default function OntimeActionForm({
)} )}
{selectedAction === 'message-secondary' && ( {selectedAction === 'message-secondary' && (
<>
<label>
Text (leave empty for no change)
<Input {...register(`outputs.${index}.text`)} fluid placeholder='eg: Next up: keynote' />
<Panel.Error>{rowErrors?.text?.message}</Panel.Error>
</label>
<label> <label>
Timer secondary source Timer secondary source
<Select<SecondarySource | 'null' | null> <Select<SecondarySource | 'null' | null>
@@ -145,6 +151,7 @@ export default function OntimeActionForm({
/> />
<Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error> <Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error>
</label> </label>
</>
)} )}
<div className={style.test}>{children}</div> <div className={style.test}>{children}</div>
@@ -54,6 +54,16 @@ const eventStaticPropertiesNext = [
'{{eventNext.delay}}', '{{eventNext.delay}}',
]; ];
const groupStaticPropertiesNow = [
'{{groupNow.id}}',
'{{groupNow.title}}',
'{{groupNow.note}}',
'{{groupNow.colour}}',
'{{groupNow.timeStart}}',
'{{groupNow.timeEnd}}',
'{{groupNow.duration}}',
];
const staticAuxProperties = (index: 1 | 2 | 3) => [ const staticAuxProperties = (index: 1 | 2 | 3) => [
`{{auxtimer${index}.current}}`, `{{auxtimer${index}.current}}`,
`{{auxtimer${index}.duration}}`, `{{auxtimer${index}.duration}}`,
@@ -75,6 +85,8 @@ export function makeAutoCompleteList(customFields: CustomFields): string[] {
...Object.entries(customFields).map(([key]) => `{{eventNow.custom.${key}}}`), ...Object.entries(customFields).map(([key]) => `{{eventNow.custom.${key}}}`),
...eventStaticPropertiesNext, ...eventStaticPropertiesNext,
...Object.entries(customFields).map(([key]) => `{{eventNext.custom.${key}}}`), ...Object.entries(customFields).map(([key]) => `{{eventNext.custom.${key}}}`),
...groupStaticPropertiesNow,
...Object.entries(customFields).map(([key]) => `{{groupNow.custom.${key}}}`),
...staticAuxProperties(1), ...staticAuxProperties(1),
...staticAuxProperties(2), ...staticAuxProperties(2),
...staticAuxProperties(3), ...staticAuxProperties(3),
@@ -163,5 +163,51 @@ describe('parseOutput', () => {
secondarySource: 'secondary', secondarySource: 'secondary',
}); });
}); });
it('parses message-secondary with a text value', () => {
expect(
parseOutput({
type: 'ontime',
action: 'message-secondary',
secondarySource: 'secondary',
text: 'hello',
}),
).toMatchObject({
secondarySource: 'secondary',
text: 'hello',
});
// an empty text is treated as no change
expect(
parseOutput({
type: 'ontime',
action: 'message-secondary',
secondarySource: 'secondary',
text: '',
}),
).toMatchObject({
secondarySource: 'secondary',
text: undefined,
});
// text can be set while clearing the secondary source
expect(
parseOutput({
type: 'ontime',
action: 'message-secondary',
secondarySource: null,
text: 'hello',
}),
).toMatchObject({
secondarySource: null,
text: 'hello',
});
expect(() =>
parseOutput({
type: 'ontime',
action: 'message-secondary',
secondarySource: 'secondary',
text: 123,
}),
).toThrow('Unexpected payload type:');
});
}); });
}); });
@@ -226,12 +226,21 @@ function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
if (maybeOntimeAction.action === 'message-secondary') { if (maybeOntimeAction.action === 'message-secondary') {
assert.hasKeys(maybeOntimeAction, ['secondarySource']); assert.hasKeys(maybeOntimeAction, ['secondarySource']);
// the secondary text is optional, an empty string is treated as no change
let text: string | undefined = undefined;
if ('text' in maybeOntimeAction) {
assert.isString(maybeOntimeAction.text);
text = indeterminateText(maybeOntimeAction.text);
}
// null is used to clear the secondary source // null is used to clear the secondary source
if (maybeOntimeAction.secondarySource === null) { if (maybeOntimeAction.secondarySource === null) {
return { return {
type: 'ontime', type: 'ontime',
action: 'message-secondary', action: 'message-secondary',
secondarySource: null, secondarySource: null,
text,
}; };
} }
@@ -240,6 +249,7 @@ function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
type: 'ontime', type: 'ontime',
action: 'message-secondary', action: 'message-secondary',
secondarySource: chooseSecondarySource(maybeOntimeAction.secondarySource), secondarySource: chooseSecondarySource(maybeOntimeAction.secondarySource),
text,
}; };
} }
@@ -66,6 +66,7 @@ export function toOntimeAction(action: OntimeAction) {
timer: { timer: {
secondarySource: action.secondarySource, secondarySource: action.secondarySource,
}, },
secondary: action.text,
}); });
break; break;
} }
@@ -111,9 +111,9 @@ export type OntimeAction =
text?: string; text?: string;
visible?: boolean; visible?: boolean;
} }
// TODO: when setting a secondary source of type secondary we could specify a value to it
| { | {
type: 'ontime'; type: 'ontime';
action: OntimeMessageSecondary; action: OntimeMessageSecondary;
secondarySource: SecondarySource; secondarySource: SecondarySource;
text?: string;
}; };