Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
914f550
turn off debug logging for production
thinkbigthings Apr 5, 2025
769bf4f
broken state, working on ChallengeForm
thinkbigthings Apr 6, 2025
0cdb6ff
fix create challenge bug
thinkbigthings Apr 12, 2025
bcf1504
invalidate cache on save
thinkbigthings Apr 12, 2025
0ef42ad
use setQueryData instead of invalidating all query data
thinkbigthings Apr 12, 2025
09ddc5b
updated edit challenge, next use mutation for edit challenge.
thinkbigthings Apr 15, 2025
60eb30e
use mutation for edit challenge
thinkbigthings Apr 15, 2025
b5d85c4
remove comment
thinkbigthings Apr 15, 2025
e78decc
editing and saving works
thinkbigthings Apr 27, 2025
f4aac55
ready to try using mutation for delete
thinkbigthings Apr 27, 2025
53e8347
delete works
thinkbigthings Apr 27, 2025
86694a6
minor cleanup
thinkbigthings Apr 27, 2025
63c5fdc
Replace old ChallengeForm with TSQ Form for create Challenge
thinkbigthings Apr 28, 2025
9bbbdff
fix editing and validation
thinkbigthings Apr 28, 2025
440636c
ChallengeList does not need to be parameterized
thinkbigthings Apr 28, 2025
311cfc4
use ChallengeDetailDto directly to reduce mapping calls
thinkbigthings Apr 29, 2025
b877f55
fix broken build
thinkbigthings Apr 30, 2025
ab0b8d9
move validation challenges inside form
thinkbigthings May 3, 2025
585a4b4
remove unnecessary type info and update mapping annotation
thinkbigthings May 4, 2025
2dbd87e
reduce complexity metric
thinkbigthings May 4, 2025
2d3d192
rename the file to its original name
thinkbigthings May 4, 2025
d570a49
remove trivial then()
thinkbigthings May 5, 2025
3b20bb4
remove some usages of ensure
thinkbigthings May 7, 2025
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
32 changes: 17 additions & 15 deletions react/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, {useState} from 'react';
import React, {Suspense, useState} from 'react';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

Expand Down Expand Up @@ -148,20 +148,22 @@ function AuthenticatedApp() {
<Container className="d-flex">

<SideNav hasAdmin={hasAdmin()} publicId={currentUser.publicId}/>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/users/:publicId/sleep/list" element={<SleepList createdCount={createdCount} />} />
<Route path="/login" element={<Login />} />
<Route path="/users/:publicId/challenge" element={<Challenge />} />
<Route path="/users/:publicId/sleep/chart" element={<SleepChart createdCount={createdCount} />} />
<Route path="/users/:publicId/histogram" element={<Histogram createdCount = {createdCount} />} />
<Route path="/users" element={<UserList/>} />
<Route path="/users/:publicId/edit" element={<EditUser />} />
<Route path="/users/:publicId/sleep/:sleepId/edit" element={<EditSleep />} />
<Route path="/users/:publicId/challenge/:challengeId/edit" element={<EditChallenge />} />
<Route path="/users/:publicId/tools" element={<Tools />} />
<Route path="/users/:publicId/chat" element={<Chat />} />
</Routes>
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/users/:publicId/sleep/list" element={<SleepList createdCount={createdCount} />} />
<Route path="/login" element={<Login />} />
<Route path="/users/:publicId/challenge" element={<Challenge />} />
<Route path="/users/:publicId/sleep/chart" element={<SleepChart createdCount={createdCount} />} />
<Route path="/users/:publicId/histogram" element={<Histogram createdCount = {createdCount} />} />
<Route path="/users" element={<UserList/>} />
<Route path="/users/:publicId/edit" element={<EditUser />} />
<Route path="/users/:publicId/sleep/:sleepId/edit" element={<EditSleep />} />
<Route path="/users/:publicId/challenge/:challengeId/edit" element={<EditChallenge />} />
<Route path="/users/:publicId/tools" element={<Tools />} />
<Route path="/users/:publicId/chat" element={<Chat />} />
</Routes>
</Suspense>
</Container>

</HashRouter>
Expand Down
57 changes: 26 additions & 31 deletions react/src/Challenge.tsx
Original file line number Diff line number Diff line change
@@ -1,72 +1,67 @@
import React, {useEffect, useState} from 'react';
import React from 'react';
import Container from "react-bootstrap/Container";
import {NavHeader} from "./App";
import {useParams} from "react-router-dom";
import {GET} from "./utility/BasicHeaders";
import {Tab, Tabs} from "react-bootstrap";
import { emptyChallengeList} from "./utility/Constants";
import CollapsibleChallenge from "./component/CollapsibleChallenge";
import useApiDelete from "./hooks/useApiDelete";
import CreateChallenge from "./CreateChallenge";
import {toChallengeList} from "./utility/Mapper";
import {ChallengeDetailDto} from "./types/challenge.types";
import {ChallengeDetailDto, ChallengeList} from "./types/challenge.types.ts";
import {useMutation, useQueryClient, useSuspenseQuery} from "@tanstack/react-query";
import {toChallengeList} from "./utility/Mapper.ts";
import {httpDelete, httpGet} from "./utility/apiClient.ts";

function Challenge() {

const {publicId} = useParams();
const callDelete = useApiDelete();

// the user's current date is used to determine challenge completion status
const challengeEndpoint = `/api/user/${publicId}/challenge`;
const challengeUrl = `/api/user/${publicId}/challenge`;

const [createdCount, setCreatedCount] = useState(0);
const [deletedCount, setDeletedCount] = useState(0);
const [savedChallenges, setSavedChallenges] = useState(emptyChallengeList);
const queryClient = useQueryClient();

useEffect(() => {
fetch(challengeEndpoint, GET)
.then((response) => response.json() as Promise<ChallengeDetailDto[]>)
.then(toChallengeList)
.then(setSavedChallenges)
.catch(error => console.log(error));
}, [createdCount, deletedCount]);
const deleteMutation = useMutation({
mutationFn: (url: string) => httpDelete(url),
onSuccess: () => queryClient.invalidateQueries({queryKey: [challengeUrl]})
});

const deleteChallenge = (challengeId: number) => {
const endpoint = `/api/user/${publicId}/challenge/${challengeId}`;
callDelete(endpoint).then(() => setDeletedCount(deletedCount + 1));
deleteMutation.mutate(`${challengeUrl}/${challengeId}`);
}

const {data: savedChallenges} = useSuspenseQuery<ChallengeDetailDto[], Error, ChallengeList>({
queryKey: [challengeUrl],
queryFn: () => httpGet<ChallengeDetailDto[]>(challengeUrl),
select: toChallengeList
});

return (
<Container>

<NavHeader title="Sleep Challenge">
<CreateChallenge onCreated={() => setCreatedCount(createdCount+1)}
savedChallenges={[...savedChallenges.completed, ...savedChallenges.current, ...savedChallenges.upcoming]}
/>
<CreateChallenge challengeUrl={challengeUrl} />
</NavHeader>

<Container className="container mt-3 px-0">
<Container className="mt-3 px-0">
<Tabs defaultActiveKey="current" id="challenge-tabs">
<Tab eventKey="current" title="Current">
<Container className="px-0 overflow-y-scroll h-70vh ">
{savedChallenges.current.map((challenge, index) =>
<CollapsibleChallenge key={index} challenge={challenge}
{savedChallenges.current.map(challenge =>
<CollapsibleChallenge key={challenge.id} challenge={challenge}
onDelete={() => deleteChallenge(challenge.id)} />
)}
</Container>
</Tab>
<Tab eventKey="completed" title="Completed">
<Container className="px-0 overflow-y-scroll h-70vh ">
{savedChallenges.completed.map((challenge, index) =>
<CollapsibleChallenge key={index} challenge={challenge}
{savedChallenges.completed.map(challenge =>
<CollapsibleChallenge key={challenge.id} challenge={challenge}
onDelete={() => deleteChallenge(challenge.id)} />
)}
</Container>
</Tab>
<Tab eventKey="upcoming" title="Future">
<Container className="px-0 overflow-y-scroll h-70vh ">
{savedChallenges.upcoming.map((challenge, index) =>
<CollapsibleChallenge key={index} challenge={challenge}
{savedChallenges.upcoming.map(challenge =>
<CollapsibleChallenge key={challenge.id} challenge={challenge}
onDelete={() => deleteChallenge(challenge.id)} />
)}
</Container>
Expand Down
107 changes: 59 additions & 48 deletions react/src/ChallengeForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faExclamationTriangle} from "@fortawesome/free-solid-svg-icons";
import Row from "react-bootstrap/Row";
import Col from "react-bootstrap/Col";
import {ChallengeData} from "./types/challenge.types";
import {localDateToJsDate, jsDateToLocalDate} from "./utility/Mapper.ts";
import {ChallengeData, ChallengeDetailDto} from "./types/challenge.types";
import {localDateToJsDate, jsDateToLocalDate, toLocalChallengeData} from "./utility/Mapper.ts";
import {useSuspenseQuery} from "@tanstack/react-query";
import {httpGet} from "./utility/apiClient.ts";


const overlaps = (c1: ChallengeData, c2: ChallengeData): boolean => {
Expand All @@ -17,58 +19,67 @@ const overlaps = (c1: ChallengeData, c2: ChallengeData): boolean => {
return ! ( c1.finish.isBefore(c2.start) || c1.start.isAfter(c2.finish) );
};

function ChallengeForm(props:{
setEditableChallenge:React.Dispatch<React.SetStateAction<ChallengeData>>
editableChallenge:ChallengeData,
setDataValid:React.Dispatch<React.SetStateAction<boolean>>,
savedChallenges:ChallengeData[]}
) {
interface ChallengeFormProps {
challengeUrl:string,
draftChallenge:ChallengeData,
onValidityChanged: (valid: boolean) => void,
onChallengeChanged: (latestDraft: ChallengeData) => void,
}

const {setEditableChallenge, editableChallenge, setDataValid, savedChallenges} = props;
function ChallengeForm(props:ChallengeFormProps) {

// validation of individual fields for validation feedback to the user
const [dateOrderValid, setDateOrderValid] = useState(true);
const [nameValid, setNameValid] = useState(true);
const [nameUnique, setNameUnique] = useState(true);
const {challengeUrl, draftChallenge, onValidityChanged, onChallengeChanged} = props;

// select affects the returned data value but does not affect what gets stored in the query cache
// filter out the current challenge (a new challenge will have id 0 and not remove anything)
const {data: validationChallenges} = useSuspenseQuery<ChallengeDetailDto[], Error, ChallengeData[]>({
queryKey: [challengeUrl],
queryFn: () => httpGet<ChallengeDetailDto[]>(challengeUrl),
select: data => data
.filter(challenge => challenge.id !== draftChallenge.id)
.map(challenge => toLocalChallengeData(challenge))
});

// this is a warning, so we don't disable the save button
const [datesOverlap, setDatesOverlap] = useState(false);

// Add a state variable to track user interaction with the name field
const [nameTouched, setNameTouched] = useState(false);

// validation of individual fields for validation feedback to the user
const [dateOrderValid, setDateOrderValid] = useState(true);
const [nameSpacesValid, setNameSpacesValid] = useState(true);
const [nameUnique, setNameUnique] = useState(true);

const validateChallenge = (challenge: ChallengeData) => {

// name validation to consider if the user has interacted with the field
const nameValid = nameTouched
? (challenge.name !== '' && challenge.name.trim() === challenge.name)
: true;
// warnings
const newDatesOverlap = validationChallenges.some(saved => overlaps(challenge, saved) );
setDatesOverlap(newDatesOverlap);

// errors
const newNameSpacesValid = (challenge.name !== '' && challenge.name.trim() === challenge.name);
const newNameUnique = !validationChallenges.some(saved => saved.name === challenge.name);
const newDateOrderValid = challenge.start.isBefore(challenge.finish);

const dateOrderValid = challenge.start.isBefore(challenge.finish);
const nameUnique = !savedChallenges.some(saved => saved.name === challenge.name);
const datesOverlap = savedChallenges.some(saved => overlaps(challenge, saved) );
const newValidity = newDateOrderValid && newNameSpacesValid && newNameUnique;

setDateOrderValid(dateOrderValid);
setNameValid(nameValid);
setNameUnique(nameUnique);
setDatesOverlap(datesOverlap);
setDataValid(dateOrderValid && nameValid && nameUnique);
setNameSpacesValid(newNameSpacesValid);
setDateOrderValid(newDateOrderValid);
setNameUnique(newNameUnique);

onValidityChanged(newValidity);
};

// useEffect to run validation on component mount and whenever editableChallenge changes
// useEffect to run validation on component mount and whenever draft changes
// This is so the validation is run when the form is populated from outside
// (e.g. when the user selects a predefined challenge)
// and not just when the user edits the form
useEffect(() => {
validateChallenge(editableChallenge);
}, [editableChallenge]);
validateChallenge(draftChallenge);
}, [draftChallenge]);

const updateChallenge = (updateValues: Partial<ChallengeData> ) => {
const updatedChallenge:ChallengeData = {...editableChallenge, ...updateValues};
setEditableChallenge(updatedChallenge);
const updatedChallenge:ChallengeData = {...draftChallenge, ...updateValues};
validateChallenge(updatedChallenge);
onChallengeChanged(updatedChallenge);
}


Expand All @@ -86,22 +97,22 @@ function ChallengeForm(props:{
className="form-control"
id="challengeName"
placeholder=""
value={editableChallenge.name}
onChange={e => {
setNameTouched(true);
updateChallenge({name: e.target.value})}
}
isInvalid={!nameValid || !nameUnique}
/>
value={draftChallenge.name}
onChange={e => updateChallenge({name: e.target.value})}
isInvalid={!nameSpacesValid || !nameUnique} />
</Container>
<Form.Control.Feedback type="invalid"
className={"mh-24px d-block " + ((!nameValid) ? 'visible' : 'invisible')}>
className={"mh-24px d-block " + (!nameSpacesValid ? 'visible' : 'invisible')}>
Can't be empty or have space at the ends
</Form.Control.Feedback>
<Container className="ps-0 mb-3 pe-0">
<textarea rows={6} className="form-control" id="description" placeholder="Description"
value={editableChallenge.description}
onChange={e => updateChallenge({description: e.target.value})}/>
<Form.Control
as="textarea"
rows={6}
id="description"
placeholder="Description"
value={draftChallenge.description}
onChange={e => updateChallenge({description: e.target.value})} />
</Container>

<Container id="dateRangeId" className="p-0">
Expand All @@ -113,7 +124,7 @@ function ChallengeForm(props:{
<DatePicker className={"form-control " + ((!dateOrderValid) ? 'border-danger' : '')}
id="startDate" dateFormat="MMMM d, yyyy"
onChange={date => {if(date) updateChallenge({start: jsDateToLocalDate(date)})}}
selected={localDateToJsDate(editableChallenge.start)}/>
selected={localDateToJsDate(draftChallenge.start)}/>
</Col>
</Row>
<Row className={"pb-2"}>
Expand All @@ -122,9 +133,9 @@ function ChallengeForm(props:{
</Col>
<Col md={6} className={"col-8 "}>
<DatePicker className={"form-control " + ((!dateOrderValid) ? 'border-danger' : '')}
id="startDate" dateFormat="MMMM d, yyyy"
id="endDate" dateFormat="MMMM d, yyyy"
onChange={date => {if(date) updateChallenge({finish: jsDateToLocalDate(date)})}}
selected={localDateToJsDate(editableChallenge.finish)}/>
selected={localDateToJsDate(draftChallenge.finish)}/>
</Col>
</Row>
<Row>
Expand All @@ -137,7 +148,7 @@ function ChallengeForm(props:{
</Form>
<label className={"text-warning " + ((datesOverlap) ? 'visible' : 'invisible')}>
<FontAwesomeIcon icon={faExclamationTriangle} className={"pe-1"}/>
This date range overlaps another challenge which is not recommended
This date range overlaps another challenge
</label>
</>
);
Expand Down
16 changes: 7 additions & 9 deletions react/src/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, {useCallback, useEffect, useRef, useState} from 'react';
import Container from "react-bootstrap/Container";
import {NavHeader} from "./App";
import {useParams} from "react-router-dom";
import {httpDelete, httpGet, httpPost, PostVariables} from "./utility/apiClient.ts";
import {httpDelete, httpGet, httpPost, UploadVars} from "./utility/apiClient.ts";
import Row from "react-bootstrap/Row";
import Col from "react-bootstrap/Col";
import {MessageDto, MessageType} from "./types/message.types.ts";
Expand All @@ -16,9 +16,6 @@ import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query";
function Chat() {

const {publicId} = useParams();
if (publicId === undefined) {
throw new Error("Public ID is required in the url");
}

const chatUrl = `/api/user/${publicId}/chat`

Expand All @@ -35,10 +32,10 @@ function Chat() {
});

const uploadMessageMutation = useMutation({
mutationFn: (variables: PostVariables) => httpPost<MessageDto>(variables.url, variables.body),
mutationFn: (variables: UploadVars<MessageDto>) => httpPost<MessageDto,MessageDto>(variables.url, variables.body),
onSuccess: (message: MessageDto) => {
setShowProcessingIcon(false);
queryClient.setQueryData([chatUrl], (oldData: MessageDto[] | undefined) => [
queryClient.setQueryData([chatUrl], (oldData: MessageDto[]) => [
...(oldData ?? []),
message,
]);
Expand All @@ -51,7 +48,7 @@ function Chat() {

const userPrompt: MessageDto = { content: prompt.trim(), type: MessageType.USER };

queryClient.setQueryData([chatUrl], (oldData: MessageDto[] | undefined) => [
queryClient.setQueryData([chatUrl], (oldData: MessageDto[]) => [
...(oldData ?? []),
userPrompt,
]);
Expand Down Expand Up @@ -87,8 +84,9 @@ function Chat() {
<NavHeader title="Chat">
<InfoModalButton
className={"me-1"}
titleText={"Chat History"}
modalText={"Chat history is only available for the last 7 days"} />
titleText={"Chat About Sleep"}
modalText={"Talk with an AI counsellor about how to improve your sleep. " +
"Chat history is only available for the last 7 days"} />
<WarningButton buttonText="Delete" onConfirm={deleteChat}>
This deletes the entire conversation and cannot be undone. Proceed?
</WarningButton>
Expand Down
Loading
Loading