Repository: jku-vds-lab/loops (language: python)

Issue:
Show state meta information

Currently only the version is shown
![image](https://github.com/jku-vds-lab/loops/assets/10337788/fbfcd9c2-ef81-445a-b558-1237255152cc)


Show additional information like number of aggregated states, last execution, etc.


Discussion / hints:
```
/**
 * Translates seconds into human readable format of seconds, minutes, hours, days, and years
 * 
 * @param  {number} seconds The number of seconds to be processed
 * @return {string}         The phrase describing the amount of time
 */
function forHumans ( seconds ) {
    var levels = [
        [Math.floor(seconds / 31536000), 'years'],
        [Math.floor((seconds % 31536000) / 86400), 'days'],
        [Math.floor(((seconds % 31536000) % 86400) / 3600), 'hours'],
        [Math.floor((((seconds % 31536000) % 86400) % 3600) / 60), 'minutes'],
        [(((seconds % 31536000) % 86400) % 3600) % 60, 'seconds'],
    ];
    var returntext = '';

    for (var i = 0, max = levels.length; i < max; i++) {
        if ( levels[i][0] === 0 ) continue;
        returntext += ' ' + levels[i][0] + ' ' + (levels[i][0] === 1 ? levels[i][1].substr(0, levels[i][1].length-1): levels[i][1]);
    };
    return returntext.trim();
}
```

https://stackoverflow.com/questions/8211744/convert-time-interval-given-in-seconds-into-more-human-readable-form

```
function formatTimeDuration(timestamp1, timestamp2) {
  const diffInSeconds = Math.abs(Math.floor((timestamp2 - timestamp1) / 1000));

  if (diffInSeconds < 60) {
    return diffInSeconds + ' seconds';
  } else if (diffInSeconds < 100 * 60) {
    const diffInMinutes = Math.floor(diffInSeconds / 60);
    return diffInMinutes + ' minutes';
  } else if (diffInSeconds < 24 * 3600) {
    const diffInHours = Math.floor(diffInSeconds / 3600);
    return diffInHours + ' hours';
  } else {
    const diffInDays = Math.floor(diffInSeconds / (24 * 3600));
    return diffInDays + ' days';
  }
}
```
https://chat.openai.com/share/97eebe66-3856-4ec7-957e-bb94bdeac347

Fix patch:
diff --git a/package.json b/package.json
index 8a3280c..6c62609 100644
--- a/package.json
+++ b/package.json
@@ -63,6 +63,7 @@
   "dependencies": {
     "@armantang/html-diff": "^0.0.5",
     "@emotion/react": "^11.10.5",
+    "@github/relative-time-element": "^4.3.0",
     "@jupyterlab/application": "^4.0.2",
     "@jupyterlab/apputils": "^4.0.2",
     "@jupyterlab/cells": "^4.0.2",
diff --git a/src/Overview/LoopsSidebar.tsx b/src/Overview/LoopsSidebar.tsx
index 6531ce0..171c011 100644
--- a/src/Overview/LoopsSidebar.tsx
+++ b/src/Overview/LoopsSidebar.tsx
@@ -69,7 +69,7 @@ interface ILoopsOverviewProbs {
 function LoopsOverview({ nbTracker, labShell }: ILoopsOverviewProbs): JSX.Element {
   const { classes } = useStyles();
   return (
-    <div className={classes.loopsOverviewRoot} id="overview-root">
+    <div lang="en" className={classes.loopsOverviewRoot} id="overview-root">
       <OverviewHeader labShell={labShell}></OverviewHeader>
       <StateList nbTracker={nbTracker} labShell={labShell} />
     </div>
diff --git a/src/Overview/State.tsx b/src/Overview/State.tsx
index cf0536f..db478e5 100644
--- a/src/Overview/State.tsx
+++ b/src/Overview/State.tsx
@@ -8,8 +8,9 @@ import { CellProvenance, NotebookProvenance } from '../Provenance/JupyterListene
 import { useLoopStore } from '../LoopStore';
 import { ActionIcon } from '@mantine/core';
 import { IconArrowsHorizontal, IconArrowsDiff } from '@tabler/icons-react';
-import { mergeArrays } from '../util';
+import { makePlural, mergeArrays } from '../util';
 import { ExecutionBadge } from './ExecutionBadge';
+import '@github/relative-time-element';
 
 const useStyles = createStyles((theme, _params, getRef) => ({
   header: {
@@ -146,9 +147,19 @@ interface IStateProps {
   previousState?: NotebookProvenance;
   stateNo: number;
   cellExecutionCounts: Map<string, number>;
+  timestamp: Date;
+  numStates: number;
 }
 
-export function State({ state, stateNo, previousState, stateDoI, cellExecutionCounts }: IStateProps): JSX.Element {
+export function State({
+  state,
+  stateNo,
+  previousState,
+  stateDoI,
+  cellExecutionCounts,
+  timestamp,
+  numStates
+}: IStateProps): JSX.Element {
   const { classes, cx } = useStyles();
 
   const [fullWidth, setFullWidth] = useState(stateDoI === 1); // on first render, initialize with stateDoI
@@ -244,7 +255,23 @@ export function State({ state, stateNo, previousState, stateDoI, cellExecutionCo
         <div className={cx(classes.state, 'state')}>
           <div style={{ height: '100vh' }} className={classes.dashedBorder}></div>
           {cells}
-          <div className={cx(classes.versionSplit)}>v{stateNo}</div>
+          <div className={cx(classes.versionSplit)}>
+            {!fullWidth ? (
+              <div>v{stateNo + 1}</div>
+            ) : (
+              <>
+                <div>
+                  v{stateNo + 1},{' '}
+                  <relative-time datetime={timestamp.toISOString()} precision="second">
+                    {timestamp.toLocaleTimeString()} {timestamp.toLocaleDateString()}
+                  </relative-time>
+                </div>
+                <small>
+                  {numStates} {makePlural('State', numStates)}
+                </small>
+              </>
+            )}
+          </div>
           <div style={{ height: '100vh' }}></div>
         </div>
       </div>
diff --git a/src/Overview/StateList.tsx b/src/Overview/StateList.tsx
index 6fdec96..dbb33a7 100644
--- a/src/Overview/StateList.tsx
+++ b/src/Overview/StateList.tsx
@@ -142,6 +142,8 @@ export function StateList({ nbTracker, labShell }: IStateListProps): JSX.Element
           stateNo={thisLastState.stateNo}
           stateDoI={thisLastState.stateDoI}
           cellExecutionCounts={cellExecutionCounts}
+          timestamp={new Date(thisLastState.node.createdOn)}
+          numStates={aggregatedState.length}
         />
         // <AggState
         //   key={i}
diff --git a/src/util.ts b/src/util.ts
index 5dda88a..ccceacd 100644
--- a/src/util.ts
+++ b/src/util.ts
@@ -33,3 +33,64 @@ export function mergeArrays(newArray: string[], oldArray: string[] | undefined):
 
   return mergedArray;
 }
+
+/**
+ * Translates seconds into human readable format of seconds, minutes, hours, days, and years
+ *
+ * @param  {number} seconds The number of seconds to be processed
+ * @return {string}         The phrase describing the amount of time
+ */
+export function formatTimeDuration(timestamp1, timestamp2) {
+  const diffInSeconds = Math.abs(Math.floor((timestamp2 - timestamp1) / 1000));
+
+  if (diffInSeconds < 60) {
+    // < 1min
+    return diffInSeconds + ' ' + makePlural('second', diffInSeconds);
+  } else if (diffInSeconds < 100 * 60) {
+    // < 100min
+    const diffInMinutes = Math.floor(diffInSeconds / 60);
+    return diffInMinutes + ' ' + makePlural('minute', diffInMinutes);
+  } else if (diffInSeconds < 24 * 3600) {
+    // < 1 day
+    const diffInHours = Math.floor(diffInSeconds / 3600);
+    const remainingDiffInMinutes = Math.floor((diffInSeconds % 3600) / 60);
+
+    //if diffInHours is 1, also return the minutes
+    if (diffInHours === 1) {
+      return (
+        diffInHours +
+        ' ' +
+        makePlural('hour', diffInHours) +
+        ' and ' +
+        remainingDiffInMinutes +
+        ' ' +
+        makePlural('minute', remainingDiffInMinutes)
+      );
+    }
+
+    return diffInHours + ' ' + makePlural('hour', diffInHours);
+  } else {
+    // >= 1 day
+    const diffInDays = Math.floor(diffInSeconds / (24 * 3600));
+    const remainingDiffInHours = Math.floor((diffInSeconds % (24 * 3600)) / 3600);
+
+    //if diffInDays is 1, also return the hours
+    if (diffInDays === 1) {
+      return (
+        diffInDays +
+        ' ' +
+        makePlural('day', diffInDays) +
+        ' and ' +
+        remainingDiffInHours +
+        ' ' +
+        makePlural('hour', remainingDiffInHours)
+      );
+    }
+
+    return diffInDays + ' ' + makePlural('day', diffInDays);
+  }
+}
+
+export function makePlural(text: string, amount: number): string {
+  return amount === 1 ? text : `${text}s`;
+}
diff --git a/yarn.lock b/yarn.lock
index d3fdf32..60914bd 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1797,6 +1797,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@github/relative-time-element@npm:^4.3.0":
+  version: 4.3.0
+  resolution: "@github/relative-time-element@npm:4.3.0"
+  checksum: 9e198090cee2ea636f11ca0b89b64de5cb5bdf017bad68b8c33809f345ad4743b6dc5abf32fbfe9e4401c4595d48d683084b344d6125f3e21b4da390e141986f
+  languageName: node
+  linkType: hard
+
 "@humanwhocodes/config-array@npm:^0.11.8":
   version: 0.11.8
   resolution: "@humanwhocodes/config-array@npm:0.11.8"
@@ -8100,6 +8107,7 @@ __metadata:
   dependencies:
     "@armantang/html-diff": ^0.0.5
     "@emotion/react": ^11.10.5
+    "@github/relative-time-element": ^4.3.0
     "@jupyterlab/application": ^4.0.2
     "@jupyterlab/apputils": ^4.0.2
     "@jupyterlab/builder": ^4.0.0


Test patch:
diff --git a/src/__tests__/formatTimeDuration.spec.ts b/src/__tests__/formatTimeDuration.spec.ts
new file mode 100644
index 0000000..7dd468f
--- /dev/null
+++ b/src/__tests__/formatTimeDuration.spec.ts
@@ -0,0 +1,46 @@
+import { expect, jest, test } from '@jest/globals';
+import { formatTimeDuration, makePlural } from '../util';
+
+const mockedMakePlural = jest.fn(makePlural);
+
+describe('formatTimeDuration', () => {
+  it('should format time correctly for durations less than 1 minute', () => {
+    mockedMakePlural.mockReturnValue('seconds');
+    expect(formatTimeDuration(0, 45 * 1000)).toBe('45 seconds');
+
+    mockedMakePlural.mockReturnValue('second');
+    expect(formatTimeDuration(0, 1 * 1000)).toBe('1 second');
+  });
+
+  it('should format time correctly for durations less than 100 minutes', () => {
+    mockedMakePlural.mockReturnValue('minutes');
+    expect(formatTimeDuration(0, 50 * 60 * 1000)).toBe('50 minutes');
+
+    mockedMakePlural.mockReturnValue('minute');
+    expect(formatTimeDuration(0, 79 * 60 * 1000)).toBe('79 minutes');
+  });
+
+  it('should format time correctly for durations less than 1 day', () => {
+    mockedMakePlural.mockReturnValueOnce('hours');
+    expect(formatTimeDuration(0, 4 * 3600 * 1000 + 30 * 60 * 1000)).toBe('4 hours'); // not '4 hours and 30 minutes'
+
+    mockedMakePlural.mockReturnValueOnce('hour');
+    mockedMakePlural.mockReturnValueOnce('minutes');
+    expect(formatTimeDuration(0, 1 * 3600 * 1000 + 45 * 60 * 1000)).toBe('1 hour and 45 minutes');
+
+    mockedMakePlural.mockReturnValue('hours');
+    expect(formatTimeDuration(0, 12 * 3600 * 1000)).toBe('12 hours');
+  });
+
+  it('should format time correctly for durations greater than or equal to 1 day', () => {
+    mockedMakePlural.mockReturnValueOnce('day');
+    mockedMakePlural.mockReturnValueOnce('hours');
+    expect(formatTimeDuration(0, 1 * 24 * 3600 * 1000 + 8 * 3600 * 1000)).toBe('1 day and 8 hours');
+
+    mockedMakePlural.mockReturnValueOnce('days');
+    expect(formatTimeDuration(0, 5 * 24 * 3600 * 1000 + 3 * 3600 * 1000)).toBe('5 days'); // not '5 days and 3 hours'
+
+    mockedMakePlural.mockReturnValue('days');
+    expect(formatTimeDuration(0, 7 * 24 * 3600 * 1000)).toBe('7 days');
+  });
+});
diff --git a/src/__tests__/makePlural.spec.ts b/src/__tests__/makePlural.spec.ts
new file mode 100644
index 0000000..f4840a9
--- /dev/null
+++ b/src/__tests__/makePlural.spec.ts
@@ -0,0 +1,15 @@
+import { makePlural } from '../util'; // Replace with the correct path to your makePlural file
+
+describe('makePlural', () => {
+  it('should add "s" to the end of the text when the amount is 0 or greater than 1', () => {
+    expect(makePlural('apple', 0)).toBe('apples');
+    expect(makePlural('banana', 2)).toBe('bananas');
+    expect(makePlural('orange', 10)).toBe('oranges');
+  });
+
+  it('should not add "s" to the end of the text when the amount is exactly 1', () => {
+    expect(makePlural('apple', 1)).toBe('apple');
+    expect(makePlural('banana', 1)).toBe('banana');
+    expect(makePlural('orange', 1)).toBe('orange');
+  });
+});
diff --git a/src/__tests__/loops.spec.ts b/src/__tests__/mergeArray.spec.ts
similarity index 92%
rename from src/__tests__/loops.spec.ts
rename to src/__tests__/mergeArray.spec.ts
index 4ae18ee..f88d711 100644
--- a/src/__tests__/loops.spec.ts
+++ b/src/__tests__/mergeArray.spec.ts
@@ -9,9 +9,7 @@ describe('mergeArrays', () => {
 
     expect(mergedArray).toEqual(['id1', 'id2', 'id5', 'id3', 'id4']);
   });
-});
 
-describe('mergeArrays', () => {
   it('merges arrays while preserving order and removing duplicates', () => {
     const newerArray: string[] = ['id1', 'id2', 'id3', 'id4', 'id6', 'id7']; // missing id5, add id3
     const olderArray: string[] = ['id1', 'id2', 'id4', 'id5', 'id6', 'id7']; // missing id3
@@ -21,9 +19,7 @@ describe('mergeArrays', () => {
     console.log(mergedArray);
     expect(mergedArray).toEqual(['id1', 'id2', 'id3', 'id4', 'id5', 'id6', 'id7']);
   });
-});
 
-describe('mergeArrays', () => {
   it('merges arrays with oldArray being shorter than newArry', () => {
     const newerArray: string[] = ['id1', 'id2', 'id3', 'id4', 'id6', 'id7'];
     const olderArray: string[] = ['id7'];
@@ -33,9 +29,7 @@ describe('mergeArrays', () => {
     console.log(mergedArray);
     expect(mergedArray).toEqual(['id1', 'id2', 'id3', 'id4', 'id6', 'id7']);
   });
-});
 
-describe('mergeArrays', () => {
   it('merges arrays with one being undefined', () => {
     const newerArray: string[] = ['id1', 'id2', 'id3', 'id4', 'id6', 'id7'];
     const olderArray = undefined;
@@ -45,9 +39,7 @@ describe('mergeArrays', () => {
     console.log(mergedArray);
     expect(mergedArray).toEqual(['id1', 'id2', 'id3', 'id4', 'id6', 'id7']);
   });
-});
 
-describe('mergeArrays', () => {
   it('merges arrays with oldarray being empty', () => {
     const newerArray: string[] = ['id1', 'id2', 'id3', 'id4', 'id6', 'id7'];
     const olderArray = [];
@@ -57,9 +49,7 @@ describe('mergeArrays', () => {
     console.log(mergedArray);
     expect(mergedArray).toEqual(['id1', 'id2', 'id3', 'id4', 'id6', 'id7']);
   });
-});
 
-describe('mergeArrays', () => {
   it('merges arrays with newArray being shorter than oldArray', () => {
     const newerArray: string[] = ['id1', 'id2'];
     const olderArray: string[] = ['id1', 'id5', 'id3'];
@@ -68,9 +58,7 @@ describe('mergeArrays', () => {
 
     expect(mergedArray).toEqual(['id1', 'id2', 'id5', 'id3']);
   });
-});
 
-describe('mergeArrays', () => {
   it('merges arrays with newArray being empty', () => {
     const newerArray: string[] = [];
     const olderArray: string[] = ['id1', 'id5', 'id3'];