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' && (
<label>
Timer secondary source
<Select<SecondarySource | 'null' | null>
onValueChange={(value) => {
// null -> no selection
if (value === null) return;
// 'null' -> clear the secondary source
if (value === 'null') {
setValue(`outputs.${index}.secondarySource`, null, { shouldDirty: true });
return;
}
setValue(`outputs.${index}.secondarySource`, value, { shouldDirty: true });
}}
value={watch(`outputs.${index}.secondarySource`)}
options={[
{ value: null, label: 'Select secondary source' },
{ value: 'aux1', label: 'Auxiliary timer 1' },
{ value: 'aux2', label: 'Auxiliary timer 2' },
{ 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>
<>
<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>
Timer secondary source
<Select<SecondarySource | 'null' | null>
onValueChange={(value) => {
// null -> no selection
if (value === null) return;
// 'null' -> clear the secondary source
if (value === 'null') {
setValue(`outputs.${index}.secondarySource`, null, { shouldDirty: true });
return;
}
setValue(`outputs.${index}.secondarySource`, value, { shouldDirty: true });
}}
value={watch(`outputs.${index}.secondarySource`)}
options={[
{ value: null, label: 'Select secondary source' },
{ value: 'aux1', label: 'Auxiliary timer 1' },
{ value: 'aux2', label: 'Auxiliary timer 2' },
{ 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>
@@ -54,6 +54,16 @@ const eventStaticPropertiesNext = [
'{{eventNext.delay}}',
];
const groupStaticPropertiesNow = [
'{{groupNow.id}}',
'{{groupNow.title}}',
'{{groupNow.note}}',
'{{groupNow.colour}}',
'{{groupNow.timeStart}}',
'{{groupNow.timeEnd}}',
'{{groupNow.duration}}',
];
const staticAuxProperties = (index: 1 | 2 | 3) => [
`{{auxtimer${index}.current}}`,
`{{auxtimer${index}.duration}}`,
@@ -75,6 +85,8 @@ export function makeAutoCompleteList(customFields: CustomFields): string[] {
...Object.entries(customFields).map(([key]) => `{{eventNow.custom.${key}}}`),
...eventStaticPropertiesNext,
...Object.entries(customFields).map(([key]) => `{{eventNext.custom.${key}}}`),
...groupStaticPropertiesNow,
...Object.entries(customFields).map(([key]) => `{{groupNow.custom.${key}}}`),
...staticAuxProperties(1),
...staticAuxProperties(2),
...staticAuxProperties(3),
@@ -163,5 +163,51 @@ describe('parseOutput', () => {
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') {
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
if (maybeOntimeAction.secondarySource === null) {
return {
type: 'ontime',
action: 'message-secondary',
secondarySource: null,
text,
};
}
@@ -240,6 +249,7 @@ function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
type: 'ontime',
action: 'message-secondary',
secondarySource: chooseSecondarySource(maybeOntimeAction.secondarySource),
text,
};
}
@@ -66,6 +66,7 @@ export function toOntimeAction(action: OntimeAction) {
timer: {
secondarySource: action.secondarySource,
},
secondary: action.text,
});
break;
}
@@ -111,9 +111,9 @@ export type OntimeAction =
text?: string;
visible?: boolean;
}
// TODO: when setting a secondary source of type secondary we could specify a value to it
| {
type: 'ontime';
action: OntimeMessageSecondary;
secondarySource: SecondarySource;
text?: string;
};