I wanted a MainUI widget that displays the historical state of an item.
I wrote a JS rule that processes each item I want to display as such into a new item holding a JSON string that can later be ingested by a MainUI repeater widget. The rule maps item states to labels and colours. This looks like this in the end:
This is the JS rule to process, it needs to be adapted to each item possible values, in this version I process a Contact
item.
You need to create a String
item with the same name as the item which value you want to display and the _HIST
suffix. This item should not be persisted. I added all the items I wanted to process in a dedicated gTimelineWidget
group.
This is my first attempt to the subject, it is not very flexible and has lots of room for improvements. You’ll need to modify the rule and the widget to display something else than 24h (processGroupMember
function and labels at the end of the widget.)
If the widget constantly change states, it might tax a bit too much the CPU to compute the string item as it is fully reprocessed for each change.
rules.JSRule({
name: "Historic state query for gTimelineWidget members",
description: "Queries historic state of all members of a group for the last 24 hours on every change",
triggers: [
triggers.GroupStateChangeTrigger("gTimelineWidget"),
triggers.GenericCronTrigger("0 */5 * * * ?") // Run every 5 minutes
],
execute: (data) => {
// If triggered by cron, process all members
if (data.cronExpression) {
processGroupMembers("gTimelineWidget");
} else {
// Otherwise process only the changed item
var changedItemName = data.itemName;
processGroupMember(changedItemName);
}
}
});
// Process all group members' historic states on file reload
processGroupMembers("gTimelineWidget");
function processGroupMembers(groupName) {
var group = items.getItem(groupName);
group.members.forEach(member => {
processGroupMember(member.name);
});
}
function processGroupMember(itemName) {
var member = items.getItem(itemName);
var now = new Date();
var start = time.Instant.ofEpochMilli(new Date(now.getTime() - (24 * 3600 * 1000)).getTime());
var queryStart = new Date(now.getTime() - (25 * 3600 * 1000));
var histItemName = itemName + "_HIST";
var historicStates = member.persistence.getAllStatesSince(queryStart);
var result = [];
var previousTime = start;
// console.log("Processing item: " + itemName);
// console.log("Historic states count: " + historicStates.length);
// console.log("Start time: " + start);
// historicStates.forEach(persisted => {
// console.log("State: " + persisted.state + ", Timestamp: " + persisted.instant);
// })
// Initialize first data point either at first provided state or at start time
var previousState = null;
var previousTime = start;
if (historicStates[0]?.instant.isBefore(previousTime)) {
previousTime = historicStates[0]?.instant;
}
historicStates.push({
state: member.state,
instant: time.Instant.ofEpochMilli(now.getTime())
})
// The list of states may not begin before start, in that case the first state is null
// Process each historic state
historicStates.forEach((persisted, idx) => {
// If the state is different from the previous state or if it is the last, process the state
if (persisted.state != previousState || idx == historicStates.length - 1) {
// If the state is after the start time, create a new entry
if (persisted.instant.isAfter(start)) {
// Clamp to start of period for duration calculation
var duration = (previousTime.isBefore(start) ? start : previousTime).until(persisted.instant, time.ChronoUnit.SECONDS);
result.push({
dur: Math.round(duration*1000 / (24*3600))/10,
col: mapValueToColor(previousState),
txt: mapValueToText(previousState),
start: previousTime,
end: persisted.instant,
})
}
previousState = persisted.state;
previousTime = persisted.instant;
}
})
var payload = {
states: result,
lastTxt: result[result.length - 1]?.txt,
lastCol: result[result.length - 1]?.col,
end: now
}
var json = JSON.stringify(payload);
console.log("Result: " + json);
// Send the result to the corresponding item
items.getItem(histItemName).sendCommand(json);
}
function mapValueToColor(value) {
// Implement your value-to-color mapping logic here
if (value == "CLOSED") {
return "rgb(56, 204, 101)";
} else if (value == "OPEN") {
return "red";
} else {
return "gray";
}
}
function mapValueToText(value) {
if (value == "CLOSED") {
return "Vide";
} else if (value == "OPEN") {
return "Occupé";
} else {
return "N/A";
}
}
The code of the custom widget is the following. It is a div so I don’t think it can be added directly to a layout, I embedded it in a custom card widget.
uid: vas_timeline_div
tags: []
props:
parameters:
- context: item
description: Item holding history over the last period
label: History item
name: itmHist
required: true
type: TEXT
parameterGroups: []
timestamp: Apr 9, 2025, 4:45:42 PM
component: oh-context
config:
variables:
key: =Math.random().toString(36).substring(2, 10);
data: =JSON.parse(items[props.itmHist].state)
slots:
default:
- component: div
config:
stylesheet: |
.timeline {
display: flex;
border-radius: 4px;
overflow: hidden;
background-color: transparent;
margin: 0px;
padding: 0px;
width: 100%;
flex-grow: 1;
} .period-div {
display: block;
position: relative;
font-size: 10px;
padding: 0px;
min-width: 0px;
} .timeline-scale-container {
width: 100%;
display: flex;
-webkit-box-pack: justify;
justify-content: space-between;
} .timeline-scale-label {
flex: 1 1 0%;
color: #808893;
font-size: 10px
}
config: {}
slots:
default:
- component: oh-repeater
config:
containerClasses: timeline
for: repeat
fragment: false
in: =JSON.parse(items[props.itmHist].state).states
slots:
default:
- component: f7-button
config:
class: period-div
popover-open: =".popover-patch-"+loop.repeat_idx+"-"+vars.key
style:
background-color: =loop.repeat.col + ' !important'
border-radius: 0
height: 8px
width: =loop.repeat.dur +'%'
text: =".popover-patch-"+loop.repeat_idx
- component: f7-popover
config:
backdrop: false
class: =['popover', 'popover-patch-'+loop.repeat_idx+"-"+vars.key]
close-on-escape: true
style:
--f7-popover-bg-color: black
--f7-card-bg-color: black
text: =loop.repeat.txt
vertical-position: bottom
slots:
default:
- component: f7-card
config:
style:
color: white
slots:
default:
- component: Label
config:
text: =dayjs(loop.repeat.start).format('H:mm') + ' - ' +
dayjs(loop.repeat.end).format('H:mm')
- component: div
slots:
default:
- component: div
config:
style:
border-radius: 50%
display: inline-block
width: 10px
height: 10px
background-color: =loop.repeat.col
- component: Content
config:
text: =loop.repeat.txt
- component: div
config:
class: timeline-scale-container
slots:
default:
- component: span
config:
class: timeline-scale-label
content: "=items[props.itmHist].state.startsWith('{') ?
dayjs(JSON.parse(items[props.itmHist].state).end).subtrac\
t(24, 'hour').format('H:mm') : ''"
- component: span
config:
class: timeline-scale-label
content: "=items[props.itmHist].state.startsWith('{') ?
dayjs(JSON.parse(items[props.itmHist].state).end).subtrac\
t(12, 'hour').format('H:mm') : ''"
style:
text-align: center
- component: span
config:
class: timeline-scale-label
content: Maintenant
style:
text-align: end