diff --git a/README.md b/README.md index 977ed92..13f529a 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,9 @@ You can test the web app locally using Wing Console. 2. Run `wing it main.w` to launch the Wing Console in your browser. 3. In the Wing Console, locate the website resource, and click on it to see its properties on the right sidebar. Click on the URL property to open visit the website in your browser. +For working on the React app, you can `cd` into the `website` directory and run `npm run start` to start the React app, which will automatically connect to the Wing simulator if you have the Wing Console running. +The page will automatically reload if you make changes to the React code. + ### Deployment To deploy your own copy of the app, first make sure you have AWS credentials configured in your terminal for the account and region you want to deploy to. diff --git a/dynamodb.w b/dynamodb.w index 44adf54..b1d851d 100644 --- a/dynamodb.w +++ b/dynamodb.w @@ -16,12 +16,6 @@ pub struct Attribute { value: Json; } -pub class Util { - extern "./util.js" pub static inflight jsonToMutArray(json: Json): MutArray>; - extern "./util.js" pub static inflight jsonToArray(json: Json): Array>; - extern "./util.js" pub static inflight mutArrayToJson(json: MutArray>): Json; -} - interface IDynamoDBTable { inflight getItem(map: Map): Map?; inflight putItem(item: Map): void; @@ -38,23 +32,44 @@ struct DynamoDBTableProps { pub class DynamoDBTableSim impl IDynamoDBTable { key: str; data: cloud.Bucket; + hashKey: str; new(props: DynamoDBTableProps) { this.key = "data.json"; this.data = new cloud.Bucket(); this.data.addObject(this.key, "[]"); + this.hashKey = props.hashKey; } pub inflight putItem(item: Map) { - let items = this.data.getJson(this.key); - let itemsMut = Util.jsonToMutArray(items); - itemsMut.push(item); - this.data.putJson(this.key, Util.mutArrayToJson(itemsMut)); + // Check if the item has the hash key + if !item.has(this.hashKey) { + throw("Item does not have the hash key"); + } + + let items: Json = this.data.getJson(this.key); + let itemsMut: MutArray> = unsafeCast(items); + + // Check if the item already exists by looking in the array for an item with the same hash key + for existingItem in itemsMut { + if existingItem.get(this.hashKey).value == item.get(this.hashKey).value { + // If it does, update the item + for key in item.keys() { + existingItem.set(key, item.get(key)); + } + this.data.putJson(this.key, unsafeCast(itemsMut)); + return; + } + } + + // If the item doesn't exist, add it to the array + itemsMut.push(item.copyMut()); + this.data.putJson(this.key, unsafeCast(itemsMut)); } pub inflight getItem(map: Map): Map? { - let items = this.data.getJson(this.key); - let itemsMut = Util.jsonToMutArray(items); + let items: Json = this.data.getJson(this.key); + let itemsMut: MutArray> = unsafeCast(items); for item in itemsMut { let var matches = true; for key in map.keys() { @@ -73,8 +88,8 @@ pub class DynamoDBTableSim impl IDynamoDBTable { } pub inflight scan(): Array> { - let items = this.data.getJson(this.key); - return Util.jsonToArray(items); + let items: Json = this.data.getJson(this.key); + return unsafeCast(items); } pub onLift(host: std.IInflightHost, ops: Array) { diff --git a/main.w b/main.w index 05934f1..56a6f37 100644 --- a/main.w +++ b/main.w @@ -1,6 +1,8 @@ bring "./dynamodb.w" as ddb; bring cloud; +bring fs; bring math; +bring util; // --- types --- @@ -40,8 +42,6 @@ struct SelectWinnerResponse { } class Util { - extern "./util.js" static inflight jsonToSelectWinnerRequest(json: Json): SelectWinnerRequest; - pub static inflight clamp(value: num, min: num, max: num): num { if value < min { return min; @@ -116,8 +116,8 @@ class Store { // probability that the winner should have won let pWinner = 1.0 / (1.0 + 10 ** ((loserScore - winnerScore) / 400.0)); - let winnerNewScore = Util.clamp(winnerScore + 32 * (1.0 - pWinner), 1000, 2000); - let loserNewScore = Util.clamp(loserScore + 32 * (pWinner - 1.0), 1000, 2000); + let winnerNewScore = Util.clamp(winnerScore + 32 * (1.0 - pWinner), 0, 2000); + let loserNewScore = Util.clamp(loserScore + 32 * (pWinner - 1.0), 0, 2000); this.setEntry(Entry { name: winner, score: winnerNewScore }); this.setEntry(Entry { name: loser, score: loserNewScore }); @@ -187,7 +187,7 @@ new cloud.OnDeploy(inflight () => { if !store.getEntry(food)? { store.setEntry(Entry { name: food, - score: 1500, + score: 1000 + math.floor(math.random() * 100) - 50, // 1000 +/- 50 }); } } @@ -198,6 +198,14 @@ let api = new cloud.Api(cors: true) as "VotingAppApi"; let website = new cloud.Website(path: "./website/build"); website.addJson("config.json", { apiUrl: api.url }); +// A hack to expose the api url to the React app for local development +if util.env("WING_TARGET") == "sim" { + new cloud.OnDeploy(inflight () => { + fs.writeFile("node_modules/.votingappenv", api.url); + }) as "ReactAppSetup"; +} + + // Select two random items from the list of items for the user to choose between api.post("/requestChoices", inflight (_) => { let entries = store.getRandomPair(); diff --git a/util.js b/util.js deleted file mode 100644 index e6092bb..0000000 --- a/util.js +++ /dev/null @@ -1,11 +0,0 @@ -exports.jsonToMutArray = function(value) { - return value; -} - -exports.jsonToArray = function(value) { - return value; -} - -exports.mutArrayToJson = function(value) { - return value; -} diff --git a/website/src/components/VoteItem.tsx b/website/src/components/VoteItem.tsx index 7fd921d..0a66074 100644 --- a/website/src/components/VoteItem.tsx +++ b/website/src/components/VoteItem.tsx @@ -76,21 +76,24 @@ counter-reset: ${safeName} ${score}; } @keyframes ${safeName}-anim { - 0% { counter-reset: ${safeName} ${safeScore + 15 * isWinner}; } - 3% { counter-reset: ${safeName} ${safeScore + 14 * isWinner}; } - 6% { counter-reset: ${safeName} ${safeScore + 13 * isWinner}; } - 9% { counter-reset: ${safeName} ${safeScore + 12 * isWinner}; } - 12% { counter-reset: ${safeName} ${safeScore + 11 * isWinner}; } - 15% { counter-reset: ${safeName} ${safeScore + 10 * isWinner}; } - 18% { counter-reset: ${safeName} ${safeScore + 9 * isWinner}; } -21% { counter-reset: ${safeName} ${safeScore + 8 * isWinner}; } -24% { counter-reset: ${safeName} ${safeScore + 7 * isWinner}; } -27% { counter-reset: ${safeName} ${safeScore + 6 * isWinner}; } -30% { counter-reset: ${safeName} ${safeScore + 5 * isWinner}; } -33% { counter-reset: ${safeName} ${safeScore + 4 * isWinner}; } -36% { counter-reset: ${safeName} ${safeScore + 3 * isWinner}; } -43% { counter-reset: ${safeName} ${safeScore + 2 * isWinner}; } -66% { counter-reset: ${safeName} ${safeScore + 1 * isWinner}; } +0% { counter-reset: ${safeName} ${safeScore + 18 * isWinner}; } +3% { counter-reset: ${safeName} ${safeScore + 17 * isWinner}; } +6% { counter-reset: ${safeName} ${safeScore + 16 * isWinner}; } +9% { counter-reset: ${safeName} ${safeScore + 15 * isWinner}; } +12% { counter-reset: ${safeName} ${safeScore + 14 * isWinner}; } +15% { counter-reset: ${safeName} ${safeScore + 13 * isWinner}; } +18% { counter-reset: ${safeName} ${safeScore + 12 * isWinner}; } +21% { counter-reset: ${safeName} ${safeScore + 11 * isWinner}; } +24% { counter-reset: ${safeName} ${safeScore + 10 * isWinner}; } +27% { counter-reset: ${safeName} ${safeScore + 9 * isWinner}; } +30% { counter-reset: ${safeName} ${safeScore + 8 * isWinner}; } +33% { counter-reset: ${safeName} ${safeScore + 7 * isWinner}; } +36% { counter-reset: ${safeName} ${safeScore + 6 * isWinner}; } +39% { counter-reset: ${safeName} ${safeScore + 5 * isWinner}; } +42% { counter-reset: ${safeName} ${safeScore + 4 * isWinner}; } +50% { counter-reset: ${safeName} ${safeScore + 3 * isWinner}; } +60% { counter-reset: ${safeName} ${safeScore + 2 * isWinner}; } +80% { counter-reset: ${safeName} ${safeScore + 1 * isWinner}; } 100% { counter-reset: ${safeName} ${safeScore}; } }`} diff --git a/website/src/services/fetchChoices.ts b/website/src/services/fetchChoices.ts index 89f830f..31407f6 100644 --- a/website/src/services/fetchChoices.ts +++ b/website/src/services/fetchChoices.ts @@ -1,3 +1,4 @@ +import { useEffect, useRef, useState } from "react"; import { fetchConfig } from "./fetchConfig"; export interface Choice { @@ -19,7 +20,7 @@ const getImageSvg = async (label: string): Promise => { }); }; -export const fetchChoices = async () => { +const fetchChoices = async (): Promise> => { const apiUrl = (await fetchConfig()).apiUrl; const response = await fetch(apiUrl + "/requestChoices", { method: "POST", @@ -37,3 +38,40 @@ export const fetchChoices = async () => { ); return choices; }; + +export const useFetchChoices = () => { + const [choices, setChoices] = useState([ + { label: "" }, + { label: "" }, + ]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const hasFetchedInitialChoices = useRef(false); + + const fetchNewChoices = async (userInitiated: boolean) => { + // Prevent automatic second fetch in development mode + if (hasFetchedInitialChoices.current && !userInitiated) { + return; + } + + hasFetchedInitialChoices.current = true; + + setChoices([{ label: "" }, { label: "" }]); + setIsLoading(true); + setError(null); + try { + const newChoices = await fetchChoices(); + setChoices(newChoices); + } catch (err) { + setError((err as any).message); + } finally { + setIsLoading(false); + } + } + + useEffect(() => { + fetchNewChoices(false); + }, []); + + return { choices, isLoading, error, fetchNewChoices: () => fetchNewChoices(true) }; +}; diff --git a/website/src/services/fetchConfig.ts b/website/src/services/fetchConfig.ts index cfdb896..44d4866 100644 --- a/website/src/services/fetchConfig.ts +++ b/website/src/services/fetchConfig.ts @@ -2,11 +2,24 @@ export interface Config { apiUrl: string; } +let cachedConfig: Config | null = null; + export const fetchConfig = async () => { + // In production, use the cached config if available + if (process.env.NODE_ENV === 'production' && cachedConfig) { + return cachedConfig; + } + const response = await fetch("./config.json"); if (!response.ok) { throw new Error("Failed to fetch config"); } const config: Config = await response.json(); + + // Cache the config if in production mode + if (process.env.NODE_ENV === 'production') { + cachedConfig = config; + } + return config; }; diff --git a/website/src/services/fetchLeaderboard.ts b/website/src/services/fetchLeaderboard.ts index fab5069..5999d22 100644 --- a/website/src/services/fetchLeaderboard.ts +++ b/website/src/services/fetchLeaderboard.ts @@ -1,3 +1,4 @@ +import { useEffect, useRef, useState } from "react"; import { fetchConfig } from "./fetchConfig"; export interface Entry { @@ -5,7 +6,7 @@ export interface Entry { score: number; } -export const fetchLeaderboard = async () => { +const fetchLeaderboard = async () => { const apiUrl = (await fetchConfig()).apiUrl; const response = await fetch(apiUrl + "/leaderboard"); if (!response.ok) { @@ -14,3 +15,30 @@ export const fetchLeaderboard = async () => { const jsonData: Entry[] = await response.json(); return jsonData; }; + +export const useFetchLeaderboard = () => { + const [entries, setEntries] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + // Prevent automatic second fetch in development mode + const hasFetchedInitialEntries = useRef(false); + + useEffect(() => { + const fetchEntries = async () => { + if (!hasFetchedInitialEntries.current) { + hasFetchedInitialEntries.current = true; + setIsLoading(true); + try { + const data = await fetchLeaderboard(); + setEntries(data); + } finally { + setIsLoading(false); + } + } + }; + + fetchEntries(); + }, []); + + return { entries, isLoading }; +}; diff --git a/website/src/setupProxy.js b/website/src/setupProxy.js new file mode 100644 index 0000000..c6066c1 --- /dev/null +++ b/website/src/setupProxy.js @@ -0,0 +1,15 @@ +const fs = require('fs'); + +module.exports = function(app) { + app.use( + '/config.json', + function(req, res) { + const apiUrl = fs.readFileSync("../node_modules/.votingappenv", "utf8"); + if (!apiUrl) { + res.status(500).send("No API URL set. Are you running `wing it`?"); + return; + } + res.send({ apiUrl }); + } + ); +}; diff --git a/website/src/views/LeaderboardView.tsx b/website/src/views/LeaderboardView.tsx index d2459c0..dfa04e1 100644 --- a/website/src/views/LeaderboardView.tsx +++ b/website/src/views/LeaderboardView.tsx @@ -1,17 +1,8 @@ -import { useEffect, useState } from "react"; -import { Entry, fetchLeaderboard } from "../services/fetchLeaderboard"; +import { useFetchLeaderboard } from "../services/fetchLeaderboard"; import { SpinnerLoader } from "../components/SpinnerLoader"; export const LeaderboardView = () => { - const [entries, setEntries] = useState([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - fetchLeaderboard().then((items) => { - setEntries(items); - setLoading(false); - }); - }, []); + const { entries, isLoading } = useFetchLeaderboard(); return (
@@ -33,7 +24,7 @@ export const LeaderboardView = () => { - {loading && ( + {isLoading && ( { - const [choices, setChoices] = useState([ - { label: "" }, - { label: "" }, - ]); + const { choices, isLoading, fetchNewChoices } = useFetchChoices(); const [scores, setScores] = useState([]); - const [loading, setLoading] = useState(true); - const [loadingScores, setLoadingScores] = useState(false); - useEffect(() => { - fetchChoices().then((choices) => { - setChoices(choices); - setLoading(false); - }); - }, []); - const [winner, setWinner] = useState(); const [selectedChoice, setSelectedChoice] = useState(); @@ -45,25 +33,21 @@ export const VotingView = () => { const reset = async () => { setWinner(undefined); - setLoading(true); setScores([]); - setChoices([{ label: "" }, { label: "" }]); - const choices = await fetchChoices(); - setChoices(choices); - setLoading(false); + await fetchNewChoices(); }; return (
{choices.map((choice, index) => ( -
+
selectWinner(choice)} - disabled={loading || loadingScores} + disabled={isLoading || loadingScores} loading={loadingScores && selectedChoice?.label === choice.label} winner={winner} score={Math.floor(scores[index])} @@ -76,7 +60,7 @@ export const VotingView = () => {