Skip to content

Commit 670c687

Browse files
committed
Add useSimulationHistory and History component
1 parent aa36c8f commit 670c687

5 files changed

Lines changed: 377 additions & 3 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import React from "react";
2+
import { Button, Typography } from "@equinor/eds-core-react";
3+
import { Entry, useSimulationHistory } from "@/hooks/useSimulationHistory.ts";
4+
5+
const SimulationHistory: React.FC = () => {
6+
const simulationHistory = useSimulationHistory();
7+
const dayGroups: Record<string, Entry[]> = {};
8+
for (let i = simulationHistory.length - 1; i >= 0; i--) {
9+
const entry = simulationHistory[i];
10+
const y = entry.createdAt.getFullYear();
11+
const m = entry.createdAt.getMonth();
12+
const d = entry.createdAt.getDate();
13+
14+
(dayGroups[`${y}-${m}-${d}`] ??= []).push(entry);
15+
}
16+
const dayGroupsEntries = Object.entries(dayGroups);
17+
18+
return (
19+
<div>
20+
{dayGroupsEntries.length === 0 ? (
21+
<Typography variant="body_short" italic>
22+
No simulation history
23+
</Typography>
24+
) : (
25+
dayGroupsEntries.map(([date, entries]) => (
26+
<div style={{ display: "flex", flexFlow: "column", gap: "1em" }} key={date}>
27+
<Typography variant="h3">{date}</Typography>
28+
{entries.map((entry) => (
29+
<Button variant="outlined" href={`/simulations/${entry.id}`} key={entry.id}>
30+
{entry.displayName} @ {entry.createdAt.getHours()}:{entry.createdAt.getMinutes()}
31+
</Button>
32+
))}
33+
</div>
34+
))
35+
)}
36+
</div>
37+
);
38+
};
39+
40+
export default SimulationHistory;

frontend/src/components/TopBar.tsx

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,25 @@
11
import React, { useRef, useState } from "react";
2-
import { Button, Icon, Menu, TopBar as EDS_TopBar } from "@equinor/eds-core-react";
3-
import { help_outline, log_out, log_in, thermostat, launch, opacity, IconData, menu } from "@equinor/eds-icons";
2+
import { Button, Icon, Menu, SideSheet, TopBar as EDS_TopBar } from "@equinor/eds-core-react";
3+
import {
4+
help_outline,
5+
log_out,
6+
log_in,
7+
thermostat,
8+
launch,
9+
opacity,
10+
IconData,
11+
menu,
12+
history,
13+
} from "@equinor/eds-icons";
414

515
import { useMsal } from "@azure/msal-react";
616
import config from "@/configuration";
717
import { Link } from "react-router-dom";
818
import { useSettings } from "@/contexts/SettingsContext";
919
import { LargeScreenOnly, SmallScreenOnly } from "@/components/styles";
1020
import { useQuery } from "@tanstack/react-query";
21+
import SimulationHistory from "@/components/SimulationHistorySidebar.tsx";
22+
import styled from "styled-components";
1123

1224
type NavItem = {
1325
label: string;
@@ -33,6 +45,15 @@ const navItems: NavItem[] = [
3345
},
3446
];
3547

48+
const Background = styled.div`
49+
position: fixed;
50+
left: 0;
51+
top: 0;
52+
width: 100dvw;
53+
height: 100dvh;
54+
background-color: rgba(0, 0, 0, 0.5);
55+
`;
56+
3657
const TemperatureToggle: React.FC = () => {
3758
const { temperature, nextTemperature } = useSettings();
3859

@@ -44,6 +65,28 @@ const TemperatureToggle: React.FC = () => {
4465
);
4566
};
4667

68+
const SimulationHistoryToggle: React.FC<{ withLabel?: boolean }> = ({ withLabel }) => {
69+
const [open, setOpen] = useState<boolean>(false);
70+
71+
return (
72+
<>
73+
<Button onClick={() => setOpen(!open)} variant={withLabel ? "ghost" : "ghost_icon"}>
74+
<Icon data={history} />
75+
{withLabel && "History"}
76+
</Button>
77+
<Background onClick={() => setOpen(false)} style={{ display: open ? "block" : "none" }} />
78+
<SideSheet
79+
open={open}
80+
onClose={() => setOpen(false)}
81+
style={{ minHeight: "100dvh" }}
82+
title="Simulation History"
83+
>
84+
<SimulationHistory />
85+
</SideSheet>
86+
</>
87+
);
88+
};
89+
4790
const ProfilePhoto: React.FC = () => {
4891
const { instance } = useMsal();
4992
const account = instance.getActiveAccount();
@@ -91,6 +134,7 @@ const SmallScreenActions: React.FC = () => {
91134

92135
return (
93136
<SmallScreenOnly>
137+
<SimulationHistoryToggle />
94138
<Button variant="ghost_icon" onClick={() => setOpen(!open)} ref={ref}>
95139
<Icon data={menu} />
96140
</Button>
@@ -143,6 +187,7 @@ const LargeScreenActions: React.FC = () => {
143187
return (
144188
<LargeScreenOnly>
145189
<TemperatureToggle />
190+
<SimulationHistoryToggle withLabel />
146191

147192
{account ? (
148193
<>
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import * as z from "zod";
2+
import { useSyncExternalStore } from "react";
3+
4+
export const Entry = z.object({
5+
createdAt: z.coerce.date(),
6+
finishedAt: z.coerce.date().optional(),
7+
displayName: z.string(),
8+
id: z.uuid(),
9+
});
10+
export type Entry = z.infer<typeof Entry>;
11+
type EntryWithIndex = Entry & { index: number };
12+
type ContextType = {
13+
entries: EntryWithIndex[];
14+
nextIndex: number;
15+
};
16+
17+
const getFromStorage = (storage?: Record<string, string>): ContextType => {
18+
storage ??= localStorage;
19+
20+
const entries: EntryWithIndex[] = [];
21+
let nextIndex = 0;
22+
for (const key in storage) {
23+
const keyMatch = key.match(/^simulation\[(\d+)]$/);
24+
if (keyMatch === null) continue;
25+
26+
const index = +keyMatch[1];
27+
if (index >= nextIndex) nextIndex = index + 1;
28+
29+
const value = storage[key]!;
30+
const entry = Entry.safeParse(JSON.parse(value));
31+
if (entry.success) {
32+
entries.push({ ...entry.data, index });
33+
} else {
34+
console.warn(`Couldn't parse saved simulation '${key}'`, value, entry.error);
35+
delete storage[key];
36+
}
37+
}
38+
39+
// Sort from oldest to newest
40+
entries.sort((lhs, rhs) => +lhs.createdAt - +rhs.createdAt);
41+
42+
return {
43+
entries,
44+
nextIndex,
45+
};
46+
};
47+
48+
class ContextStore {
49+
entries: EntryWithIndex[];
50+
nextIndex: number;
51+
listeners: Set<() => void>;
52+
storage: Record<string, string>;
53+
54+
constructor(ctx: ContextType, storage: Record<string, string>) {
55+
this.entries = ctx.entries;
56+
this.nextIndex = ctx.nextIndex;
57+
this.storage = storage;
58+
this.listeners = new Set();
59+
}
60+
61+
static fromStorage(storage: Record<string, string>): ContextStore {
62+
return new ContextStore(getFromStorage(storage), storage);
63+
}
64+
65+
addEntry(entry: Entry): void {
66+
this.entries.push({ ...entry, index: this.nextIndex });
67+
68+
// Assign to itself so that React knows this value was updated
69+
// eslint-disable-next-line no-self-assign
70+
this.entries = this.entries;
71+
72+
this.storage[`simulation[${this.nextIndex}]`] = JSON.stringify(entry);
73+
this.nextIndex += 1;
74+
75+
this.listeners.forEach((x) => x());
76+
}
77+
78+
finalizeEntry(id: string) {
79+
for (const entry of this.entries) {
80+
if (entry.id !== id) continue;
81+
82+
if (entry.finishedAt !== undefined) continue;
83+
84+
entry.finishedAt = new Date();
85+
this.storage[`simulation[${entry.index}]`] = JSON.stringify(entry);
86+
this.listeners.forEach((x) => x());
87+
return;
88+
}
89+
}
90+
91+
subscribe(callable: () => void): () => void {
92+
this.listeners.add(callable);
93+
return () => this.listeners.delete(callable);
94+
}
95+
96+
getSnapshot(): Entry[] {
97+
return this.entries;
98+
}
99+
}
100+
101+
export const simulationHistory = ContextStore.fromStorage(localStorage);
102+
103+
export const useSimulationHistory = () =>
104+
useSyncExternalStore(
105+
(listener) => simulationHistory.subscribe(listener),
106+
() => simulationHistory.getSnapshot()
107+
);
108+
109+
export const __testing__ = {
110+
getFromStorage,
111+
ContextStore,
112+
};

frontend/src/pages/Models.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import noModelImage from "@/assets/no-model-light.svg";
1616
import { useNavigate, useParams } from "react-router-dom";
1717
import DownloadButton from "@/components/DownloadButton";
1818
import { convertSimulationQueriesResultToTabulatedData, convertTabulatedDataToCSVFormat } from "@/functions/Formatting";
19+
import { simulationHistory } from "@/hooks/useSimulationHistory.ts";
1920

2021
const Models: React.FC = () => {
2122
const [currentModel, setCurrentModel] = useState<ModelConfig | undefined>(undefined);
@@ -25,7 +26,14 @@ const Models: React.FC = () => {
2526

2627
const { mutate: setModelInput } = useMutation({
2728
mutationFn: startSimulation,
28-
onSuccess: (data) => navigate(`/simulations/${data}`),
29+
onSuccess: (data, model) => {
30+
simulationHistory.addEntry({
31+
id: data,
32+
createdAt: new Date(),
33+
displayName: models.find((m) => m.modelId === model.modelId)?.displayName ?? model.modelId,
34+
});
35+
navigate(`/simulations/${data}`);
36+
},
2937
});
3038

3139
const { data: simulationResults, isLoading } = useQuery({
@@ -36,6 +44,12 @@ const Models: React.FC = () => {
3644
retryDelay: () => 2000,
3745
});
3846

47+
useEffect(() => {
48+
if (simulationId && !isLoading) {
49+
simulationHistory.finalizeEntry(simulationId);
50+
}
51+
}, [simulationId, isLoading]);
52+
3953
useEffect(() => {
4054
if (simulationResults) {
4155
const usemodel = models.find((model) => model.modelId === simulationResults.modelInput.modelId);

0 commit comments

Comments
 (0)