Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
9f241e1
chore(sidebar): accessibility — add aria-controls, region + labelledb…
justinedagreat Sep 2, 2026
318d8a8
feat: Vps 186 change the side drop downs to icons (#518)
justinedagreat Sep 2, 2026
a5c7d19
feat: "seen" resources map on user model
del-ereno Sep 19, 2026
98ffdae
feat: endpoints for "seen" resource
del-ereno Sep 19, 2026
534a820
refactor: atomic add/remove "seen" resource ids w patch
del-ereno Sep 19, 2026
4b5be57
feat: frontend hook + resource helpers
del-ereno Sep 19, 2026
8ea0713
feat: hook derives new resources, toasts, prunes
del-ereno Sep 19, 2026
62b9632
refactor: tree/query derived from playpage to avoid repeat calc
del-ereno Sep 19, 2026
a2da56b
feat: "new" marker/notif for unopened resources
del-ereno Sep 19, 2026
cfa7413
fix: clicking same resource twice only sends one seen req
del-ereno Sep 25, 2026
f4e04f4
refactor: remove "seen" prune when condition no longer being met
del-ereno Sep 25, 2026
e19ccc5
fix: toast announces the same resource repeatedly
del-ereno Sep 25, 2026
11bdb66
refactor: ResourceTree is presentation func only
del-ereno Sep 25, 2026
714a2f2
refactor: extract reused badge code to make future edits easier
del-ereno Sep 25, 2026
200e48f
refactor: dedupe tree flattening
del-ereno Sep 25, 2026
b8a024d
refactor: playerResources naming + trimmed code bloat
del-ereno Sep 25, 2026
35d6694
chore: add old comment back
del-ereno Sep 25, 2026
767fdcb
fix: separate seen marks from history fetch
del-ereno Sep 25, 2026
23bb548
feat: toast on error loading notifs
del-ereno Sep 25, 2026
69bf43d
fix: "new" resource toast once a session, no retrigger repeats
del-ereno Sep 25, 2026
a397173
Merge remote-tracking branch 'origin/master' into VPS-213/Resource-No…
del-ereno Oct 1, 2026
b047d84
fix: sync resource cache after marking "seen"
del-ereno Oct 1, 2026
8af4a9e
refactor: remove useMemos from usePlayerResources
del-ereno Oct 10, 2026
dba1655
Merge branch 'master' into VPS-213/Resource-Notifications
del-ereno Oct 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions backend/src/db/daos/userDao.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,36 @@ export const setUserProperties = async (userId, scenarioId, properties) => {
user.stateVersions.get(scenarioId),
];
};

/**
* Retrieves the ids of resources a user has seen in a scenario.
*
* @param {string} uid - Firebase user ID.
* @param {string} scenarioId - Scenario ID the "seen" list belongs to.
* @returns {Promise<string[]>} "Seen" resource IDs (empty = none).
*/
export const getSeenResources = async (uid, scenarioId) => {
const user = await User.findOne({ uid }, { seenResources: 1 });
if (!user) throw new HttpError("user not found", 404);
return user.seenResources.get(scenarioId) ?? [];
};

/**
* Atomically add seen resource IDs in a scenario.
*
* @param {string} uid - Firebase user ID.
* @param {string} scenarioId - Scenario ID.
* @param {string[]} resourceIds - Resource IDs to mark as seen.
* @returns {Promise<string[]>} Persisted seen resource IDs.
*/
export const addSeenResources = async (uid, scenarioId, resourceIds) => {
const user = await User.findOneAndUpdate(
{ uid },
{ $addToSet: { [`seenResources.${scenarioId}`]: { $each: resourceIds } } },
{ new: true, projection: { seenResources: 1 } }
);

if (!user) throw new HttpError("user not found", 404);

return user.seenResources.get(scenarioId) ?? [];
};
6 changes: 6 additions & 0 deletions backend/src/db/models/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ const userSchema = new Schema({
of: { type: Number, default: 0 },
default: {},
},
// Map of scenarioId to "seen" resource ids
seenResources: {
type: Map,
of: [String],
default: {},
},
});

const User = mongoose.model("model", userSchema, "users");
Expand Down
47 changes: 47 additions & 0 deletions backend/src/routes/api/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
retrieveUserByEmail,
createUser,
assignScenarioToUsers,
getSeenResources,
addSeenResources,
} from "../../db/daos/userDao.js";
import User from "../../db/models/user.js";
import Group from "../../db/models/group.js";
Expand All @@ -11,6 +13,7 @@ import auth from "../../middleware/firebaseAuth.js";
import STATUS from "../../util/status.js";
import { handle, HttpError } from "../../util/error.js";
import scenarioAuth from "../../middleware/scenarioAuth.js";
import { isValidObjectId } from "../../util/validation.js";

const router = Router();

Expand Down Expand Up @@ -64,6 +67,50 @@ router.get(
})
);

// get resource ids that user has seen
router.get(
"/seen-resources/:scenarioId",
handle(async (req, res) => {
const { scenarioId } = req.params;
const { uid } = req.body;

if (!isValidObjectId(scenarioId)) {
throw new HttpError("Invalid scenario ID", STATUS.BAD_REQUEST);
}

const seenResources = await getSeenResources(uid, scenarioId);

return res.status(STATUS.OK).json({ seenResources });
})
);

// mark resource ids as seen by the user
router.patch(
"/seen-resources/:scenarioId",
handle(async (req, res) => {
const { scenarioId } = req.params;
const { uid, resourceIds } = req.body;

if (!isValidObjectId(scenarioId)) {
throw new HttpError("Invalid scenario ID", STATUS.BAD_REQUEST);
}

if (
!Array.isArray(resourceIds) ||
!resourceIds.every((id) => typeof id === "string" && isValidObjectId(id))
) {
throw new HttpError(
"Resource IDs must be a valid ID string array",
STATUS.BAD_REQUEST
);
}

const seenResources = await addSeenResources(uid, scenarioId, resourceIds);

return res.status(STATUS.OK).json({ seenResources });
})
);

// NOTE: not currently used, but associated ui functionality will be added

// assign scenario to users
Expand Down
19 changes: 15 additions & 4 deletions frontend/src/features/playScenario/PlayScenarioPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
VolumeOffIcon,
} from "lucide-react";
import ResourcesPanel from "../resources/ResourcesOverlay";
import { usePlayerResources } from "../resources/usePlayerResources";

const sceneCache = new Map();

Expand Down Expand Up @@ -172,6 +173,8 @@ export default function PlayScenarioPage({ group }) {
const audioRefs = useRef([]);

const currScene = sceneCache.get(sceneId);
const playerResources = usePlayerResources(properties, Boolean(currScene));
const newResourceCount = playerResources.unseenIds.length;

const handleError = async (error) => {
if (!error) return;
Expand Down Expand Up @@ -478,12 +481,21 @@ export default function PlayScenarioPage({ group }) {
)}
<div className="tooltip tooltip-left" data-tip="Open resources">
<button
className="btn"
className="btn relative"
onClick={() => setResourcesOpen(true)}
type="button"
aria-label="Open resources"
aria-label={
newResourceCount > 0
? `Open resources (${newResourceCount} new)`
: "Open resources"
}
>
<BookMarkedIcon size={16} />
{newResourceCount > 0 && (
<span className="badge badge-sm badge-neutral px-1.5 absolute -top-1 -right-1">
{newResourceCount}
</span>
)}
</button>
</div>
</div>
Expand All @@ -501,8 +513,7 @@ export default function PlayScenarioPage({ group }) {
/>
)}
<ResourcesPanel
scenarioId={scenarioId}
properties={properties}
{...playerResources}
open={resourcesOpen}
onClose={() => setResourcesOpen(false)}
/>
Expand Down
26 changes: 19 additions & 7 deletions frontend/src/features/resources/ResourceTree.jsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import React from "react";

function NewBadge() {
return <span className="badge badge-xs badge-neutral shrink-0">New</span>;
}

export default function ResourceTree({
tree,
unseenIds,
selectedResourceId,
setSelectedResourceId,
onSelect,
}) {
const unseen = new Set(unseenIds);

return (
<>
{tree.map((resource) => (
Expand All @@ -15,6 +22,9 @@ export default function ResourceTree({
<span className="text--1 truncate flex-1" title={resource.name}>
{resource.name}
</span>
{resource.children.some((child) => unseen.has(child._id)) && (
<NewBadge />
)}
</summary>

<ul className="overflow-hidden">
Expand All @@ -28,11 +38,12 @@ export default function ResourceTree({
>
<button
type="button"
className="min-w-0 truncate text-left text--1 border-none cursor-pointer flex-1 px-3 py-1.5 h-9"
className="flex items-center gap-2 min-w-0 text-left text--1 border-none cursor-pointer flex-1 px-3 py-1.5 h-9"
title={child.name}
onClick={() => setSelectedResourceId(child._id)}
onClick={() => onSelect(child._id)}
>
{child.name}
<span className="truncate flex-1">{child.name}</span>
{unseen.has(child._id) && <NewBadge />}
</button>
</div>
</li>
Expand All @@ -45,11 +56,12 @@ export default function ResourceTree({
>
<button
type="button"
className="min-w-0 truncate text-left text--1 border-none cursor-pointer flex-1 px-3 py-1.5 h-9"
className="flex items-center gap-2 min-w-0 text-left text--1 border-none cursor-pointer flex-1 px-3 py-1.5 h-9"
title={resource.name}
onClick={() => setSelectedResourceId(resource._id)}
onClick={() => onSelect(resource._id)}
>
{resource.name}
<span className="truncate flex-1">{resource.name}</span>
{unseen.has(resource._id) && <NewBadge />}
</button>
</div>
)}
Expand Down
44 changes: 22 additions & 22 deletions frontend/src/features/resources/ResourcesOverlay.jsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import React, { useEffect, useState } from "react";
import ResourceTree from "./ResourceTree";
import { FileTextIcon, SearchIcon, XIcon } from "lucide-react";
import { useResources } from "./useResources";
import { findById } from "../../util/search";
import { buildResourceTree, filterTreeBySearch, normaliseFile } from "./util";
import { filterTreeByConditions } from "../../utils/propertyConditionalEvaluator";
import { filterTreeBySearch, flattenFiles } from "./util";
import SkeletonBody from "./ResourcesSkeleton";
import ResourcePreview from "./ResourcePreview";
import PanelOverlay from "../../components/PanelOverlay";
Expand All @@ -13,9 +10,16 @@ import ResourceTitle from "./components/ResourceTitle";
// NOTE: property filters can't change while the resources panel is
// open, so deselecting on resource hiding isn't a concern

export default function ResourcesPanel({ properties, open, onClose }) {
const { resourcesQuery } = useResources();

export default function ResourcesPanel({
tree,
unseenIds,
markSeen,
isLoading,
isError,
error,
open,
onClose,
}) {
const [selectedResourceId, setSelectedResourceId] = useState(null);
const [search, setSearch] = useState("");

Expand All @@ -29,20 +33,15 @@ export default function ResourcesPanel({ properties, open, onClose }) {
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);

const { data, isLoading, isError, error } = resourcesQuery;

const foundResource = findById(data, selectedResourceId);
const selectedResource = foundResource ? normaliseFile(foundResource) : null;

// NOTE: the filtering by properties should ideally be done on the
// server to prevent cheating, but here we filter before rendering

const resourceTree = buildResourceTree(data ?? []);
const conditionFilteredTree = filterTreeByConditions(
resourceTree,
properties
const selectedResource = flattenFiles(tree).find(
(resource) => resource._id === selectedResourceId
);
const filteredTree = filterTreeBySearch(conditionFilteredTree, search);
const filteredTree = filterTreeBySearch(tree, search);

const selectResource = (resourceId) => {
setSelectedResourceId(resourceId);
markSeen(resourceId);
};

return (
<>
Expand Down Expand Up @@ -81,7 +80,7 @@ export default function ResourcesPanel({ properties, open, onClose }) {
<span>{error.message}</span>
</div>
</div>
) : !conditionFilteredTree?.length ? (
) : !tree.length ? (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<p>
Expand Down Expand Up @@ -117,8 +116,9 @@ export default function ResourcesPanel({ properties, open, onClose }) {
)}
<ResourceTree
tree={filteredTree}
unseenIds={unseenIds}
selectedResourceId={selectedResourceId}
setSelectedResourceId={setSelectedResourceId}
onSelect={selectResource}
/>
</ul>
</div>
Expand Down
43 changes: 43 additions & 0 deletions frontend/src/features/resources/usePlayerResources.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useEffect, useRef } from "react";
import toast from "react-hot-toast";
import { filterTreeByConditions } from "../../utils/propertyConditionalEvaluator";
import { buildResourceTree, flattenFiles } from "./util";
import { useResources } from "./useResources";
import { useSeenResources } from "./useSeenResources";

export function usePlayerResources(properties, enabled) {
const { resourcesQuery } = useResources();
const { seenIds, isLoaded, markSeen } = useSeenResources();
const announcedIds = useRef(new Set());
const ready = enabled && isLoaded && resourcesQuery.isSuccess;

// NOTE: the filtering by properties should ideally be done on the
// server to prevent cheating, but here we filter before rendering
const tree = filterTreeByConditions(
buildResourceTree(resourcesQuery.data ?? []),
properties
);
const seen = new Set(seenIds);
const unseenIds = ready
? flattenFiles(tree)
.map((resource) => resource._id)
.filter((id) => !seen.has(id))
: [];

// toast unseen resources once a session
useEffect(() => {
const fresh = unseenIds.filter((id) => !announcedIds.current.has(id));
fresh.forEach((id) => announcedIds.current.add(id));

const count = fresh.length;
if (count > 0) {
toast(
`You have ${count} new resource${count === 1 ? "" : "s"} available`
);
}
}, [unseenIds]);

const { isLoading, isError, error } = resourcesQuery;

return { tree, unseenIds, markSeen, isLoading, isError, error };
}
Loading
Loading