From 681922596ed2f1d5ee3c55086eac5693cb4daf2e Mon Sep 17 00:00:00 2001
From: Jeremy Magland <jmagland@flatironinstitute.org>
Date: Fri, 26 Jul 2024 13:08:09 -0400
Subject: [PATCH 1/7] project save/load to browser

---
 .../HomePage/BrowserProjectsInterface.ts      | 108 ++++++++++++++++++
 .../app/pages/HomePage/LoadProjectWindow.tsx  |  70 +++++++++++-
 .../app/pages/HomePage/SaveProjectWindow.tsx  |  75 +++++++++++-
 3 files changed, 251 insertions(+), 2 deletions(-)
 create mode 100644 gui/src/app/pages/HomePage/BrowserProjectsInterface.ts

diff --git a/gui/src/app/pages/HomePage/BrowserProjectsInterface.ts b/gui/src/app/pages/HomePage/BrowserProjectsInterface.ts
new file mode 100644
index 00000000..c43aab8d
--- /dev/null
+++ b/gui/src/app/pages/HomePage/BrowserProjectsInterface.ts
@@ -0,0 +1,108 @@
+export class BrowserProjectsInterface {
+  constructor(
+    private dbName: string = "stan-playground",
+    private storeName: string = "projects",
+  ) {}
+  async loadProject(title: string) {
+    const db = await this.openDatabase();
+    const transaction = db.transaction(this.storeName, "readonly");
+    const objectStore = transaction.objectStore(this.storeName);
+    const filename = `${title}.json`;
+    const content = await this.getTextFile(objectStore, filename);
+    if (!content) return null;
+    return JSON.parse(content);
+  }
+  async saveProject(title: string, fileManifest: { [name: string]: string }) {
+    const db = await this.openDatabase();
+    const transaction = db.transaction(this.storeName, "readwrite");
+    const objectStore = transaction.objectStore(this.storeName);
+    const filename = `${title}.json`;
+    return await this.setTextFile(
+      objectStore,
+      filename,
+      JSON.stringify(fileManifest, null, 2),
+    );
+  }
+  async listProjects(): Promise<string[]> {
+    const db = await this.openDatabase();
+    const transaction = db.transaction(this.storeName, "readonly");
+    const objectStore = transaction.objectStore(this.storeName);
+    return new Promise<string[]>((resolve, reject) => {
+      const request = objectStore.getAllKeys();
+      request.onsuccess = () => {
+        resolve(
+          request.result.map((key) => {
+            return key.toString().replace(/\.json$/, "");
+          }),
+        );
+      };
+      request.onerror = () => {
+        reject(request.error);
+      };
+    });
+  }
+  async deleteProject(title: string) {
+    const db = await this.openDatabase();
+    const transaction = db.transaction(this.storeName, "readwrite");
+    const objectStore = transaction.objectStore(this.storeName);
+    const filename = `${title}.json`;
+    await this.deleteTextFile(objectStore, filename);
+  }
+  private async openDatabase() {
+    return new Promise<IDBDatabase>((resolve, reject) => {
+      const request = indexedDB.open(this.dbName);
+      request.onupgradeneeded = () => {
+        const db = request.result;
+        if (!db.objectStoreNames.contains(this.storeName)) {
+          db.createObjectStore(this.storeName, { keyPath: "name" });
+        }
+      };
+      request.onsuccess = () => {
+        resolve(request.result);
+      };
+      request.onerror = () => {
+        reject(request.error);
+      };
+    });
+  }
+  private async getTextFile(objectStore: IDBObjectStore, filename: string) {
+    return new Promise<string | null>((resolve, reject) => {
+      const getRequest = objectStore.get(filename);
+      getRequest.onsuccess = () => {
+        resolve(getRequest.result?.content || null);
+      };
+      getRequest.onerror = () => {
+        reject(getRequest.error);
+      };
+    });
+  }
+  private async setTextFile(
+    objectStore: IDBObjectStore,
+    filename: string,
+    content: string,
+  ) {
+    return new Promise<void>((resolve, reject) => {
+      const file = { name: filename, content: content };
+      const putRequest = objectStore.put(file);
+      putRequest.onsuccess = () => {
+        resolve();
+      };
+      putRequest.onerror = () => {
+        reject(putRequest.error);
+      };
+    });
+  }
+  private async deleteTextFile(objectStore: IDBObjectStore, filename: string) {
+    return new Promise<void>((resolve, reject) => {
+      const deleteRequest = objectStore.delete(filename);
+      deleteRequest.onsuccess = () => {
+        resolve();
+      };
+      deleteRequest.onerror = () => {
+        reject(deleteRequest.error);
+      };
+    });
+  }
+}
+
+export default BrowserProjectsInterface;
diff --git a/gui/src/app/pages/HomePage/LoadProjectWindow.tsx b/gui/src/app/pages/HomePage/LoadProjectWindow.tsx
index 27706868..097043cb 100644
--- a/gui/src/app/pages/HomePage/LoadProjectWindow.tsx
+++ b/gui/src/app/pages/HomePage/LoadProjectWindow.tsx
@@ -15,6 +15,9 @@ import {
   useEffect,
   useState,
 } from "react";
+import { Hyperlink, SmallIconButton } from "@fi-sci/misc";
+import BrowserProjectsInterface from "./BrowserProjectsInterface";
+import { Delete } from "@mui/icons-material";
 
 type LoadProjectWindowProps = {
   onClose: () => void;
@@ -103,9 +106,37 @@ const LoadProjectWindow: FunctionComponent<LoadProjectWindowProps> = ({
     }
   }, [filesUploaded, importUploadedFiles]);
 
+  const [browserProjectTitles, setBrowserProjectTitles] = useState<string[]>(
+    [],
+  );
+  useEffect(() => {
+    const bpi = new BrowserProjectsInterface();
+    bpi.listProjects().then((titles) => {
+      setBrowserProjectTitles(titles);
+    });
+  }, []);
+
+  const handleOpenBrowserProject = useCallback(
+    async (title: string) => {
+      const bpi = new BrowserProjectsInterface();
+      const fileManifest = await bpi.loadProject(title);
+      if (!fileManifest) {
+        alert("Failed to load project");
+        return;
+      }
+      update({
+        type: "loadFiles",
+        files: mapFileContentsToModel(fileManifest),
+        clearExisting: true,
+      });
+      onClose();
+    },
+    [update, onClose],
+  );
+
   return (
     <div>
-      <h3>Load project</h3>
+      <h3>Upload project</h3>
       <div>
         You can upload:
         <ul>
@@ -142,6 +173,43 @@ const LoadProjectWindow: FunctionComponent<LoadProjectWindowProps> = ({
           </Button>
         </div>
       )}
+      <h3>Load from browser</h3>
+      {browserProjectTitles.length > 0 ? (
+        <table>
+          <tbody>
+            {browserProjectTitles.map((title) => (
+              <tr key={title}>
+                <td>
+                  <SmallIconButton
+                    icon={<Delete />}
+                    onClick={async () => {
+                      const ok = window.confirm(
+                        `Delete project "${title}" from browser?`,
+                      );
+                      if (!ok) return;
+                      const bpi = new BrowserProjectsInterface();
+                      await bpi.deleteProject(title);
+                      const titles = await bpi.listProjects();
+                      setBrowserProjectTitles(titles);
+                    }}
+                  />
+                </td>
+                <td>
+                  <Hyperlink
+                    onClick={() => {
+                      handleOpenBrowserProject(title);
+                    }}
+                  >
+                    {title}
+                  </Hyperlink>
+                </td>
+              </tr>
+            ))}
+          </tbody>
+        </table>
+      ) : (
+        <div>No projects found in browser storage</div>
+      )}
     </div>
   );
 };
diff --git a/gui/src/app/pages/HomePage/SaveProjectWindow.tsx b/gui/src/app/pages/HomePage/SaveProjectWindow.tsx
index 42dcd0d4..dbce45a8 100644
--- a/gui/src/app/pages/HomePage/SaveProjectWindow.tsx
+++ b/gui/src/app/pages/HomePage/SaveProjectWindow.tsx
@@ -6,6 +6,7 @@ import { ProjectContext } from "@SpCore/ProjectContextProvider";
 import saveAsGitHubGist from "@SpCore/gists/saveAsGitHubGist";
 import { triggerDownload } from "@SpUtil/triggerDownload";
 import Button from "@mui/material/Button";
+import BrowserProjectsInterface from "./BrowserProjectsInterface";
 
 type SaveProjectWindowProps = {
   onClose: () => void;
@@ -18,6 +19,7 @@ const SaveProjectWindow: FunctionComponent<SaveProjectWindowProps> = ({
   const fileManifest = mapModelToFileManifest(data);
 
   const [exportingToGist, setExportingToGist] = useState(false);
+  const [savingToBrowser, setSavingToBrowser] = useState(false);
 
   return (
     <div>
@@ -47,7 +49,7 @@ const SaveProjectWindow: FunctionComponent<SaveProjectWindowProps> = ({
         </tbody>
       </table>
       <div>&nbsp;</div>
-      {!exportingToGist && (
+      {!exportingToGist && !savingToBrowser && (
         <div>
           <Button
             onClick={async () => {
@@ -66,6 +68,14 @@ const SaveProjectWindow: FunctionComponent<SaveProjectWindowProps> = ({
           >
             Save to GitHub Gist
           </Button>
+          &nbsp;
+          <Button
+            onClick={() => {
+              setSavingToBrowser(true);
+            }}
+          >
+            Save to Browser
+          </Button>
         </div>
       )}
       {exportingToGist && (
@@ -75,6 +85,13 @@ const SaveProjectWindow: FunctionComponent<SaveProjectWindowProps> = ({
           onClose={onClose}
         />
       )}
+      {savingToBrowser && (
+        <SaveToBrowserView
+          fileManifest={fileManifest}
+          title={data.meta.title}
+          onClose={onClose}
+        />
+      )}
     </div>
   );
 };
@@ -211,4 +228,60 @@ const makeSPShareableLinkFromGistUrl = (gistUrl: string) => {
   return url;
 };
 
+type SaveToBrowserViewProps = {
+  fileManifest: Partial<FileRegistry>;
+  title: string;
+  onClose: () => void;
+};
+
+const SaveToBrowserView: FunctionComponent<SaveToBrowserViewProps> = ({
+  fileManifest,
+  title,
+  onClose,
+}) => {
+  // use IndexedDB to save the project
+  // https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Using_IndexedDB
+
+  const handleSave = useCallback(async () => {
+    try {
+      const bpi = new BrowserProjectsInterface();
+      const existingProject = await bpi.loadProject(title);
+      if (existingProject) {
+        const overwrite = window.confirm(
+          `A project with the title "${title}" already exists. Do you want to overwrite it?`,
+        );
+        if (!overwrite) {
+          return;
+        }
+      }
+      await bpi.saveProject(title, fileManifest);
+    } catch (err: any) {
+      alert(`Error saving to browser: ${err.message}`);
+    }
+    onClose();
+  }, [title, fileManifest, onClose]);
+
+  return (
+    <div className="SaveToBrowserView">
+      <h3>Save to Browser</h3>
+      <p>
+        This project will be saved to your browser as &quot;{title}&quot;.&nbsp;
+        It will be available to you on this device, but not on other devices or
+        browsers.
+      </p>
+      <div>
+        <Button
+          onClick={() => {
+            handleSave();
+          }}
+        >
+          Save to Browser
+        </Button>
+        &nbsp;
+        <Button onClick={onClose}>Cancel</Button>
+      </div>
+    </div>
+  );
+};
+
 export default SaveProjectWindow;

From cc62b225bd92f92061f67d4d63ed76efa2d7cc77 Mon Sep 17 00:00:00 2001
From: Jeremy Magland <jmagland@flatironinstitute.org>
Date: Fri, 26 Jul 2024 15:55:44 -0400
Subject: [PATCH 2/7] store to browser updates

---
 .../HomePage/BrowserProjectsInterface.ts      | 21 ++++++++-----------
 .../app/pages/HomePage/LoadProjectWindow.tsx  | 13 +++++++-----
 .../app/pages/HomePage/SaveProjectWindow.tsx  | 16 +++++++-------
 3 files changed, 25 insertions(+), 25 deletions(-)

diff --git a/gui/src/app/pages/HomePage/BrowserProjectsInterface.ts b/gui/src/app/pages/HomePage/BrowserProjectsInterface.ts
index c43aab8d..29d85ce2 100644
--- a/gui/src/app/pages/HomePage/BrowserProjectsInterface.ts
+++ b/gui/src/app/pages/HomePage/BrowserProjectsInterface.ts
@@ -4,18 +4,14 @@ export class BrowserProjectsInterface {
     private storeName: string = "projects",
   ) {}
   async loadProject(title: string) {
-    const db = await this.openDatabase();
-    const transaction = db.transaction(this.storeName, "readonly");
-    const objectStore = transaction.objectStore(this.storeName);
+    const objectStore = await this.openObjectStore("readonly");
     const filename = `${title}.json`;
     const content = await this.getTextFile(objectStore, filename);
     if (!content) return null;
     return JSON.parse(content);
   }
   async saveProject(title: string, fileManifest: { [name: string]: string }) {
-    const db = await this.openDatabase();
-    const transaction = db.transaction(this.storeName, "readwrite");
-    const objectStore = transaction.objectStore(this.storeName);
+    const objectStore = await this.openObjectStore("readwrite");
     const filename = `${title}.json`;
     return await this.setTextFile(
       objectStore,
@@ -24,9 +20,7 @@ export class BrowserProjectsInterface {
     );
   }
   async listProjects(): Promise<string[]> {
-    const db = await this.openDatabase();
-    const transaction = db.transaction(this.storeName, "readonly");
-    const objectStore = transaction.objectStore(this.storeName);
+    const objectStore = await this.openObjectStore("readonly");
     return new Promise<string[]>((resolve, reject) => {
       const request = objectStore.getAllKeys();
       request.onsuccess = () => {
@@ -42,9 +36,7 @@ export class BrowserProjectsInterface {
     });
   }
   async deleteProject(title: string) {
-    const db = await this.openDatabase();
-    const transaction = db.transaction(this.storeName, "readwrite");
-    const objectStore = transaction.objectStore(this.storeName);
+    const objectStore = await this.openObjectStore("readwrite");
     const filename = `${title}.json`;
     await this.deleteTextFile(objectStore, filename);
   }
@@ -65,6 +57,11 @@ export class BrowserProjectsInterface {
       };
     });
   }
+  private async openObjectStore(mode: IDBTransactionMode) {
+    const db = await this.openDatabase();
+    const transaction = db.transaction(this.storeName, mode);
+    return transaction.objectStore(this.storeName);
+  }
   private async getTextFile(objectStore: IDBObjectStore, filename: string) {
     return new Promise<string | null>((resolve, reject) => {
       const getRequest = objectStore.get(filename);
diff --git a/gui/src/app/pages/HomePage/LoadProjectWindow.tsx b/gui/src/app/pages/HomePage/LoadProjectWindow.tsx
index 097043cb..776f4af1 100644
--- a/gui/src/app/pages/HomePage/LoadProjectWindow.tsx
+++ b/gui/src/app/pages/HomePage/LoadProjectWindow.tsx
@@ -1,4 +1,3 @@
-import Button from "@mui/material/Button";
 import {
   FieldsContentsMap,
   FileNames,
@@ -8,6 +7,10 @@ import {
 import { ProjectContext } from "@SpCore/ProjectContextProvider";
 import { deserializeZipToFiles, parseFile } from "@SpCore/ProjectSerialization";
 import UploadFilesArea from "@SpPages/UploadFilesArea";
+import { SmallIconButton } from "@fi-sci/misc";
+import { Delete } from "@mui/icons-material";
+import { Link } from "@mui/material";
+import Button from "@mui/material/Button";
 import {
   FunctionComponent,
   useCallback,
@@ -15,9 +18,7 @@ import {
   useEffect,
   useState,
 } from "react";
-import { Hyperlink, SmallIconButton } from "@fi-sci/misc";
 import BrowserProjectsInterface from "./BrowserProjectsInterface";
-import { Delete } from "@mui/icons-material";
 
 type LoadProjectWindowProps = {
   onClose: () => void;
@@ -195,13 +196,15 @@ const LoadProjectWindow: FunctionComponent<LoadProjectWindowProps> = ({
                   />
                 </td>
                 <td>
-                  <Hyperlink
+                  <Link
                     onClick={() => {
                       handleOpenBrowserProject(title);
                     }}
+                    component={"a"}
+                    style={{ cursor: "pointer" }}
                   >
                     {title}
-                  </Hyperlink>
+                  </Link>
                 </td>
               </tr>
             ))}
diff --git a/gui/src/app/pages/HomePage/SaveProjectWindow.tsx b/gui/src/app/pages/HomePage/SaveProjectWindow.tsx
index dbce45a8..2ead6f10 100644
--- a/gui/src/app/pages/HomePage/SaveProjectWindow.tsx
+++ b/gui/src/app/pages/HomePage/SaveProjectWindow.tsx
@@ -89,7 +89,7 @@ const SaveProjectWindow: FunctionComponent<SaveProjectWindowProps> = ({
         <SaveToBrowserView
           fileManifest={fileManifest}
           title={data.meta.title}
-          onClose={onClose}
+          onCancel={onClose}
         />
       )}
     </div>
@@ -231,13 +231,13 @@ const makeSPShareableLinkFromGistUrl = (gistUrl: string) => {
 type SaveToBrowserViewProps = {
   fileManifest: Partial<FileRegistry>;
   title: string;
-  onClose: () => void;
+  onCancel: () => void;
 };
 
 const SaveToBrowserView: FunctionComponent<SaveToBrowserViewProps> = ({
   fileManifest,
   title,
-  onClose,
+  onCancel,
 }) => {
   // use IndexedDB to save the project
   // https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Using_IndexedDB
@@ -258,16 +258,16 @@ const SaveToBrowserView: FunctionComponent<SaveToBrowserViewProps> = ({
     } catch (err: any) {
       alert(`Error saving to browser: ${err.message}`);
     }
-    onClose();
-  }, [title, fileManifest, onClose]);
+    onCancel();
+  }, [title, fileManifest, onCancel]);
 
   return (
     <div className="SaveToBrowserView">
       <h3>Save to Browser</h3>
       <p>
         This project will be saved to your browser as &quot;{title}&quot;.&nbsp;
-        It will be available to you on this device, but not on other devices or
-        browsers.
+        It will be available to you on this device until you clear your browser
+        cache, but not on other devices or browsers.
       </p>
       <div>
         <Button
@@ -278,7 +278,7 @@ const SaveToBrowserView: FunctionComponent<SaveToBrowserViewProps> = ({
           Save to Browser
         </Button>
         &nbsp;
-        <Button onClick={onClose}>Cancel</Button>
+        <Button onClick={onCancel}>Cancel</Button>
       </div>
     </div>
   );

From b415613782d6e7658691c017886bd2f834b8d391 Mon Sep 17 00:00:00 2001
From: Jeremy Magland <jmagland@flatironinstitute.org>
Date: Fri, 26 Jul 2024 18:02:44 -0400
Subject: [PATCH 3/7] Update LoadProjectWindow Link component style

---
 gui/src/app/pages/HomePage/LoadProjectWindow.tsx | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/gui/src/app/pages/HomePage/LoadProjectWindow.tsx b/gui/src/app/pages/HomePage/LoadProjectWindow.tsx
index 776f4af1..dcb4f4c7 100644
--- a/gui/src/app/pages/HomePage/LoadProjectWindow.tsx
+++ b/gui/src/app/pages/HomePage/LoadProjectWindow.tsx
@@ -200,8 +200,8 @@ const LoadProjectWindow: FunctionComponent<LoadProjectWindowProps> = ({
                     onClick={() => {
                       handleOpenBrowserProject(title);
                     }}
-                    component={"a"}
-                    style={{ cursor: "pointer" }}
+                    component="button"
+                    underline="none"
                   >
                     {title}
                   </Link>

From d0eb52a738456a31b3e082ef81ffbabc5460f6da Mon Sep 17 00:00:00 2001
From: Jeremy Magland <magland@users.noreply.github.com>
Date: Mon, 29 Jul 2024 10:39:27 -0400
Subject: [PATCH 4/7] Update gui/src/app/pages/HomePage/LoadProjectWindow.tsx

Co-authored-by: Brian Ward <bward@flatironinstitute.org>
---
 gui/src/app/pages/HomePage/LoadProjectWindow.tsx | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/gui/src/app/pages/HomePage/LoadProjectWindow.tsx b/gui/src/app/pages/HomePage/LoadProjectWindow.tsx
index dcb4f4c7..74151e58 100644
--- a/gui/src/app/pages/HomePage/LoadProjectWindow.tsx
+++ b/gui/src/app/pages/HomePage/LoadProjectWindow.tsx
@@ -9,7 +9,7 @@ import { deserializeZipToFiles, parseFile } from "@SpCore/ProjectSerialization";
 import UploadFilesArea from "@SpPages/UploadFilesArea";
 import { SmallIconButton } from "@fi-sci/misc";
 import { Delete } from "@mui/icons-material";
-import { Link } from "@mui/material";
+import { Link } from "@mui/material/Link";
 import Button from "@mui/material/Button";
 import {
   FunctionComponent,

From 041176ab31c889a0dedba4b4cb1e850a7ebaf9bc Mon Sep 17 00:00:00 2001
From: Jeremy Magland <jmagland@flatironinstitute.org>
Date: Mon, 29 Jul 2024 11:14:30 -0400
Subject: [PATCH 5/7] timestamps for saved browser projects

Add browser project functions and time ago string utility
---
 .../HomePage/BrowserProjectsInterface.ts      | 78 ++++++++++++++-----
 .../app/pages/HomePage/LoadProjectWindow.tsx  | 45 ++++++-----
 .../app/pages/HomePage/SaveProjectWindow.tsx  | 13 +++-
 gui/src/app/util/timeAgoString.ts             | 28 +++++++
 4 files changed, 121 insertions(+), 43 deletions(-)
 create mode 100644 gui/src/app/util/timeAgoString.ts

diff --git a/gui/src/app/pages/HomePage/BrowserProjectsInterface.ts b/gui/src/app/pages/HomePage/BrowserProjectsInterface.ts
index 29d85ce2..3f173ed0 100644
--- a/gui/src/app/pages/HomePage/BrowserProjectsInterface.ts
+++ b/gui/src/app/pages/HomePage/BrowserProjectsInterface.ts
@@ -1,39 +1,59 @@
+import baseObjectCheck from "@SpUtil/baseObjectCheck";
+
+export type BrowserProject = {
+  title: string;
+  timestamp: number;
+  fileManifest: { [name: string]: string };
+};
+
+const isBrowserProject = (value: any): value is BrowserProject => {
+  if (!baseObjectCheck(value)) return false;
+  if (typeof value.timestamp !== "number") return false;
+  if (!baseObjectCheck(value.fileManifest)) return false;
+  for (const key in value.fileManifest) {
+    if (typeof key !== "string") return false;
+    if (typeof value.fileManifest[key] !== "string") return false;
+  }
+  return true;
+};
+
 export class BrowserProjectsInterface {
   constructor(
     private dbName: string = "stan-playground",
-    private storeName: string = "projects",
+    private dbVersion: number = 2,
+    private storeName: string = "browser-projects",
   ) {}
-  async loadProject(title: string) {
+  async loadBrowserProject(title: string) {
     const objectStore = await this.openObjectStore("readonly");
     const filename = `${title}.json`;
     const content = await this.getTextFile(objectStore, filename);
     if (!content) return null;
-    return JSON.parse(content);
+    const bp = JSON.parse(content);
+    if (!isBrowserProject(bp)) {
+      console.warn(`Invalid browser project: ${title}`);
+      return null;
+    }
+    return bp;
   }
-  async saveProject(title: string, fileManifest: { [name: string]: string }) {
+  async saveBrowserProject(title: string, browserProject: BrowserProject) {
     const objectStore = await this.openObjectStore("readwrite");
     const filename = `${title}.json`;
     return await this.setTextFile(
       objectStore,
       filename,
-      JSON.stringify(fileManifest, null, 2),
+      JSON.stringify(browserProject, null, 2),
     );
   }
-  async listProjects(): Promise<string[]> {
-    const objectStore = await this.openObjectStore("readonly");
-    return new Promise<string[]>((resolve, reject) => {
-      const request = objectStore.getAllKeys();
-      request.onsuccess = () => {
-        resolve(
-          request.result.map((key) => {
-            return key.toString().replace(/\.json$/, "");
-          }),
-        );
-      };
-      request.onerror = () => {
-        reject(request.error);
-      };
-    });
+  async getAllBrowserProjects() {
+    const titles = await this.getAllProjectTitles();
+    const browserProjects = [];
+    for (const title of titles) {
+      const browserProject = await this.loadBrowserProject(title);
+      if (browserProject) {
+        browserProjects.push(browserProject);
+      }
+    }
+    return browserProjects;
   }
   async deleteProject(title: string) {
     const objectStore = await this.openObjectStore("readwrite");
@@ -42,7 +62,7 @@ export class BrowserProjectsInterface {
   }
   private async openDatabase() {
     return new Promise<IDBDatabase>((resolve, reject) => {
-      const request = indexedDB.open(this.dbName);
+      const request = indexedDB.open(this.dbName, this.dbVersion);
       request.onupgradeneeded = () => {
         const db = request.result;
         if (!db.objectStoreNames.contains(this.storeName)) {
@@ -62,6 +82,22 @@ export class BrowserProjectsInterface {
     const transaction = db.transaction(this.storeName, mode);
     return transaction.objectStore(this.storeName);
   }
+  private async getAllProjectTitles(): Promise<string[]> {
+    const objectStore = await this.openObjectStore("readonly");
+    return new Promise<string[]>((resolve, reject) => {
+      const request = objectStore.getAllKeys();
+      request.onsuccess = () => {
+        resolve(
+          request.result.map((key) => {
+            return key.toString().replace(/\.json$/, "");
+          }),
+        );
+      };
+      request.onerror = () => {
+        reject(request.error);
+      };
+    });
+  }
   private async getTextFile(objectStore: IDBObjectStore, filename: string) {
     return new Promise<string | null>((resolve, reject) => {
       const getRequest = objectStore.get(filename);
diff --git a/gui/src/app/pages/HomePage/LoadProjectWindow.tsx b/gui/src/app/pages/HomePage/LoadProjectWindow.tsx
index 74151e58..df924aa5 100644
--- a/gui/src/app/pages/HomePage/LoadProjectWindow.tsx
+++ b/gui/src/app/pages/HomePage/LoadProjectWindow.tsx
@@ -9,7 +9,7 @@ import { deserializeZipToFiles, parseFile } from "@SpCore/ProjectSerialization";
 import UploadFilesArea from "@SpPages/UploadFilesArea";
 import { SmallIconButton } from "@fi-sci/misc";
 import { Delete } from "@mui/icons-material";
-import { Link } from "@mui/material/Link";
+import Link from "@mui/material/Link";
 import Button from "@mui/material/Button";
 import {
   FunctionComponent,
@@ -18,7 +18,10 @@ import {
   useEffect,
   useState,
 } from "react";
-import BrowserProjectsInterface from "./BrowserProjectsInterface";
+import BrowserProjectsInterface, {
+  BrowserProject,
+} from "./BrowserProjectsInterface";
+import timeAgoString from "@SpUtil/timeAgoString";
 
 type LoadProjectWindowProps = {
   onClose: () => void;
@@ -107,24 +110,25 @@ const LoadProjectWindow: FunctionComponent<LoadProjectWindowProps> = ({
     }
   }, [filesUploaded, importUploadedFiles]);
 
-  const [browserProjectTitles, setBrowserProjectTitles] = useState<string[]>(
-    [],
-  );
+  const [allBrowserProjects, setAllBrowserProjects] = useState<
+    BrowserProject[]
+  >([]);
   useEffect(() => {
     const bpi = new BrowserProjectsInterface();
-    bpi.listProjects().then((titles) => {
-      setBrowserProjectTitles(titles);
+    bpi.getAllBrowserProjects().then((p) => {
+      setAllBrowserProjects(p);
     });
   }, []);
 
   const handleOpenBrowserProject = useCallback(
     async (title: string) => {
       const bpi = new BrowserProjectsInterface();
-      const fileManifest = await bpi.loadProject(title);
-      if (!fileManifest) {
+      const browserProject = await bpi.loadBrowserProject(title);
+      if (!browserProject) {
         alert("Failed to load project");
         return;
       }
+      const { fileManifest } = browserProject;
       update({
         type: "loadFiles",
         files: mapFileContentsToModel(fileManifest),
@@ -175,37 +179,42 @@ const LoadProjectWindow: FunctionComponent<LoadProjectWindowProps> = ({
         </div>
       )}
       <h3>Load from browser</h3>
-      {browserProjectTitles.length > 0 ? (
+      {allBrowserProjects.length > 0 ? (
         <table>
           <tbody>
-            {browserProjectTitles.map((title) => (
-              <tr key={title}>
+            {allBrowserProjects.map((browserProject) => (
+              <tr key={browserProject.title}>
                 <td>
                   <SmallIconButton
                     icon={<Delete />}
                     onClick={async () => {
                       const ok = window.confirm(
-                        `Delete project "${title}" from browser?`,
+                        `Delete project "${browserProject.title}" from browser?`,
                       );
                       if (!ok) return;
                       const bpi = new BrowserProjectsInterface();
-                      await bpi.deleteProject(title);
-                      const titles = await bpi.listProjects();
-                      setBrowserProjectTitles(titles);
+                      await bpi.deleteProject(browserProject.title);
+                      const p = await bpi.getAllBrowserProjects();
+                      setAllBrowserProjects(p);
                     }}
                   />
                 </td>
                 <td>
                   <Link
                     onClick={() => {
-                      handleOpenBrowserProject(title);
+                      handleOpenBrowserProject(browserProject.title);
                     }}
                     component="button"
                     underline="none"
                   >
-                    {title}
+                    {browserProject.title}
                   </Link>
                 </td>
+                <td>
+                  <span style={{ color: "gray" }}>
+                    {timeAgoString(browserProject.timestamp)}
+                  </span>
+                </td>
               </tr>
             ))}
           </tbody>
diff --git a/gui/src/app/pages/HomePage/SaveProjectWindow.tsx b/gui/src/app/pages/HomePage/SaveProjectWindow.tsx
index 2ead6f10..75a66a6a 100644
--- a/gui/src/app/pages/HomePage/SaveProjectWindow.tsx
+++ b/gui/src/app/pages/HomePage/SaveProjectWindow.tsx
@@ -7,6 +7,7 @@ import saveAsGitHubGist from "@SpCore/gists/saveAsGitHubGist";
 import { triggerDownload } from "@SpUtil/triggerDownload";
 import Button from "@mui/material/Button";
 import BrowserProjectsInterface from "./BrowserProjectsInterface";
+import timeAgoString from "@SpUtil/timeAgoString";
 
 type SaveProjectWindowProps = {
   onClose: () => void;
@@ -245,16 +246,20 @@ const SaveToBrowserView: FunctionComponent<SaveToBrowserViewProps> = ({
   const handleSave = useCallback(async () => {
     try {
       const bpi = new BrowserProjectsInterface();
-      const existingProject = await bpi.loadProject(title);
-      if (existingProject) {
+      const existingBrowserProject = await bpi.loadBrowserProject(title);
+      if (existingBrowserProject) {
         const overwrite = window.confirm(
-          `A project with the title "${title}" already exists. Do you want to overwrite it?`,
+          `A project with the title "${title}" already exists (modified ${timeAgoString(existingBrowserProject.timestamp)}). Do you want to overwrite it?`,
         );
         if (!overwrite) {
           return;
         }
       }
-      await bpi.saveProject(title, fileManifest);
+      await bpi.saveBrowserProject(title, {
+        title,
+        timestamp: Date.now(),
+        fileManifest,
+      });
     } catch (err: any) {
       alert(`Error saving to browser: ${err.message}`);
     }
diff --git a/gui/src/app/util/timeAgoString.ts b/gui/src/app/util/timeAgoString.ts
new file mode 100644
index 00000000..e040ba44
--- /dev/null
+++ b/gui/src/app/util/timeAgoString.ts
@@ -0,0 +1,28 @@
+const timeAgoString = (timestampMsec?: number) => {
+  if (timestampMsec === undefined) return "";
+  const timestampSeconds = Math.floor(timestampMsec / 1000);
+  const now = Date.now();
+  const diff = now - timestampSeconds * 1000;
+  const diffSeconds = Math.floor(diff / 1000);
+  const diffMinutes = Math.floor(diffSeconds / 60);
+  const diffHours = Math.floor(diffMinutes / 60);
+  const diffDays = Math.floor(diffHours / 24);
+  const diffWeeks = Math.floor(diffDays / 7);
+  const diffMonths = Math.floor(diffWeeks / 4);
+  const diffYears = Math.floor(diffMonths / 12);
+  if (diffYears > 0) {
+    return `${diffYears} yr${diffYears === 1 ? "" : "s"} ago`;
+  } else if (diffWeeks > 0) {
+    return `${diffWeeks} wk${diffWeeks === 1 ? "" : "s"} ago`;
+  } else if (diffDays > 0) {
+    return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`;
+  } else if (diffHours > 0) {
+    return `${diffHours} hr${diffHours === 1 ? "" : "s"} ago`;
+  } else if (diffMinutes > 0) {
+    return `${diffMinutes} min ago`;
+  } else {
+    return `${diffSeconds} sec ago`;
+  }
+};
+
+export default timeAgoString;

From 67b7ba565a1eaa738b5e9f7c833c62a22c68c6b1 Mon Sep 17 00:00:00 2001
From: Jeremy Magland <jmagland@flatironinstitute.org>
Date: Mon, 29 Jul 2024 13:41:16 -0400
Subject: [PATCH 6/7] Update time ago calculation to use days instead of weeks

---
 gui/src/app/util/timeAgoString.ts | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/gui/src/app/util/timeAgoString.ts b/gui/src/app/util/timeAgoString.ts
index e040ba44..af3afe26 100644
--- a/gui/src/app/util/timeAgoString.ts
+++ b/gui/src/app/util/timeAgoString.ts
@@ -8,8 +8,7 @@ const timeAgoString = (timestampMsec?: number) => {
   const diffHours = Math.floor(diffMinutes / 60);
   const diffDays = Math.floor(diffHours / 24);
   const diffWeeks = Math.floor(diffDays / 7);
-  const diffMonths = Math.floor(diffWeeks / 4);
-  const diffYears = Math.floor(diffMonths / 12);
+  const diffYears = Math.floor(diffDays / 365);
   if (diffYears > 0) {
     return `${diffYears} yr${diffYears === 1 ? "" : "s"} ago`;
   } else if (diffWeeks > 0) {

From 1a098fb3b0b5e179f23072afd4161d78b1837bdb Mon Sep 17 00:00:00 2001
From: Jeremy Magland <jmagland@flatironinstitute.org>
Date: Tue, 30 Jul 2024 06:42:57 -0400
Subject: [PATCH 7/7] Format text

---
 gui/src/app/pages/HomePage/SaveProjectWindow.tsx | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/gui/src/app/pages/HomePage/SaveProjectWindow.tsx b/gui/src/app/pages/HomePage/SaveProjectWindow.tsx
index 75a66a6a..b6542a69 100644
--- a/gui/src/app/pages/HomePage/SaveProjectWindow.tsx
+++ b/gui/src/app/pages/HomePage/SaveProjectWindow.tsx
@@ -270,8 +270,8 @@ const SaveToBrowserView: FunctionComponent<SaveToBrowserViewProps> = ({
     <div className="SaveToBrowserView">
       <h3>Save to Browser</h3>
       <p>
-        This project will be saved to your browser as &quot;{title}&quot;.&nbsp;
-        It will be available to you on this device until you clear your browser
+        This project will be saved to your browser as &quot;{title}&quot;. It
+        will be available to you on this device until you clear your browser
         cache, but not on other devices or browsers.
       </p>
       <div>