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,31 +120,38 @@ export default function OntimeActionForm({
)} )}
{selectedAction === 'message-secondary' && ( {selectedAction === 'message-secondary' && (
<label> <>
Timer secondary source <label>
<Select<SecondarySource | 'null' | null> Text (leave empty for no change)
onValueChange={(value) => { <Input {...register(`outputs.${index}.text`)} fluid placeholder='eg: Next up: keynote' />
// null -> no selection <Panel.Error>{rowErrors?.text?.message}</Panel.Error>
if (value === null) return; </label>
// 'null' -> clear the secondary source <label>
if (value === 'null') { Timer secondary source
setValue(`outputs.${index}.secondarySource`, null, { shouldDirty: true }); <Select<SecondarySource | 'null' | null>
return; onValueChange={(value) => {
} // null -> no selection
setValue(`outputs.${index}.secondarySource`, value, { shouldDirty: true }); if (value === null) return;
}} // 'null' -> clear the secondary source
value={watch(`outputs.${index}.secondarySource`)} if (value === 'null') {
options={[ setValue(`outputs.${index}.secondarySource`, null, { shouldDirty: true });
{ value: null, label: 'Select secondary source' }, return;
{ value: 'aux1', label: 'Auxiliary timer 1' }, }
{ value: 'aux2', label: 'Auxiliary timer 2' }, setValue(`outputs.${index}.secondarySource`, value, { shouldDirty: true });
{ value: 'aux3', label: 'Auxiliary timer 3' }, }}
{ value: 'secondary', label: 'Secondary' }, value={watch(`outputs.${index}.secondarySource`)}
{ value: 'null', label: 'None' }, // allow the user to clear the secondary source options={[
]} { value: null, label: 'Select secondary source' },
/> { value: 'aux1', label: 'Auxiliary timer 1' },
<Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error> { value: 'aux2', label: 'Auxiliary timer 2' },
</label> { value: 'aux3', label: 'Auxiliary timer 3' },
{ value: 'secondary', label: 'Secondary' },
{ value: 'null', label: 'None' }, // allow the user to clear the secondary source
]}
/>
<Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error>
</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;
}; };