diff --git a/client/src/App.jsx b/client/src/App.jsx index 1be02d5..34ff1fc 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,4 +1,4 @@ -import { useContext, useEffect, useMemo, useRef, useState } from "react"; +import React, { useContext, useEffect, useMemo, useRef, useState } from "react"; import toast from "react-hot-toast"; import Dashboard from "./components/Dashboard.jsx"; import OnboardingFlow from "./components/OnboardingFlow.jsx"; @@ -26,7 +26,8 @@ function createSession() { elapsedSeconds: 0, completedTasks: [], actions: 0, - moodScore: 3 + moodScore: 3, + stressLevel: 3 }; } @@ -84,6 +85,28 @@ function getAdaptiveBurnRate(session, baseline) { return Math.max(0, Math.min(1, Number(score.toFixed(2)))); } +function getRecoveredBurnRate(burnRate) { + return Math.max(0, Number((burnRate - 0.1).toFixed(2))); +} + +function clampMoodScore(value) { + return Math.max(1, Math.min(5, Number(value) || 3)); +} + +function getBurnRateFromCheckIn(currentBurnRate, moodScore, stressLevel) { + const normalizedMood = clampMoodScore(moodScore); + const normalizedStress = clampMoodScore(stressLevel); + const adjustment = (normalizedStress - 3) * 0.08 + (3 - normalizedMood) * 0.05; + return Math.max(0, Math.min(1, Number((currentBurnRate + adjustment).toFixed(2)))); +} + +function readStoredExtensionCheckIn() { + try { + const rawValue = window.localStorage.getItem("wellbyExtensionCheckIn"); + return rawValue ? JSON.parse(rawValue) : null; + } catch { + return null; + } function CameraIndicator({ colors, active, onOpen }) { return ( +
+

+ Pick a game, settle in for a minute, and then head back when you feel reset. + Right now you're in {GAME_OPTIONS.find((game) => game.id === selectedGame)?.label}. +

+
+
+ {GAME_OPTIONS.map((game) => ( + + ))}
diff --git a/client/src/components/SettingsPage.jsx b/client/src/components/SettingsPage.jsx index a082727..49b3e3f 100644 --- a/client/src/components/SettingsPage.jsx +++ b/client/src/components/SettingsPage.jsx @@ -1,6 +1,7 @@ -import { useContext } from "react"; +import React,{ useContext } from "react"; import { ThemeContext } from "../context/ThemeContext.jsx"; import LeafIcon from "./LeafIcon.jsx"; +import { EXTENSION_PROMPT_INTERVAL_OPTIONS } from "../lib/constants.js"; import { hexToRgba } from "../lib/color.js"; const THEME_OPTIONS = [ @@ -13,6 +14,8 @@ const THEME_OPTIONS = [ export default function SettingsPage({ fatigueOptIn, onToggleFatigue, + extensionPromptInterval, + onSetExtensionPromptInterval, fatigueStatus, mode, onToggleMode, @@ -130,6 +133,48 @@ export default function SettingsPage({ {fatigueOptIn ? "Enabled" : "Disabled"}
+ +
+
+
+

Browser check-in timer

+

+ Pick how often the tiny Wellby tab should expand on other websites and ask for a stress check-in. +

+
+
+ {extensionPromptInterval} min +
+
+ +
+ {EXTENSION_PROMPT_INTERVAL_OPTIONS.map((option) => ( + + ))} +
+
diff --git a/client/src/components/games/ChessGame.jsx b/client/src/components/games/ChessGame.jsx index 1d99e6a..66da3de 100644 --- a/client/src/components/games/ChessGame.jsx +++ b/client/src/components/games/ChessGame.jsx @@ -1,4 +1,4 @@ -import { useContext, useEffect, useMemo, useState } from "react"; +import React,{ useContext, useEffect, useMemo, useState } from "react"; import { Chess } from "chess.js"; import { ThemeContext } from "../../context/ThemeContext.jsx"; @@ -81,6 +81,11 @@ export default function ChessGame({ onExit }) { const [captured, setCaptured] = useState({ white: [], black: [] }); const board = useMemo(() => game.board().flat(), [game]); + const winnerText = game.isGameOver() + ? game.isCheckmate() + ? `You ${game.turn() === "b" ? "win" : "lose"}!` + : "Game over." + : null; useEffect(() => { if (mode !== "ai" || game.turn() !== "b" || game.isGameOver()) { @@ -146,14 +151,16 @@ export default function ChessGame({ onExit }) { return (
-
-
-

Chess

-

Pass-and-play or a simple depth-3 AI opponent.

-
- +
+

Chess

+

+ Play pass-and-play or challenge a lighter AI opponent. +

+
-
-
+
+
{board.map((piece, index) => { const isLight = (Math.floor(index / 8) + index) % 2 === 0; const square = algebraic(index); @@ -190,7 +197,7 @@ export default function ChessGame({ onExit }) {
+
+
+ {winnerText ?? `Turn: ${game.turn() === "w" ? "White" : "Black"}`} +
+
); } diff --git a/client/src/components/games/SnakeGame.jsx b/client/src/components/games/SnakeGame.jsx index 1d753e7..fb8e083 100644 --- a/client/src/components/games/SnakeGame.jsx +++ b/client/src/components/games/SnakeGame.jsx @@ -29,6 +29,7 @@ export default function SnakeGame({ onExit }) { const [food, setFood] = useState(() => randomFood(INITIAL_SNAKE)); const [score, setScore] = useState(0); const [gameOver, setGameOver] = useState(false); + const [isRunning, setIsRunning] = useState(false); const highScores = readStorage(STORAGE_KEYS.gameScores, { snake: 0 }); useEffect(() => { @@ -57,7 +58,7 @@ export default function SnakeGame({ onExit }) { }, []); useEffect(() => { - if (gameOver) { + if (gameOver || !isRunning) { return; } @@ -98,7 +99,7 @@ export default function SnakeGame({ onExit }) { }, 140); return () => clearInterval(interval); - }, [food, gameOver, highScores, score]); + }, [food, gameOver, highScores, isRunning, score]); useEffect(() => { const canvas = canvasRef.current; @@ -126,31 +127,55 @@ export default function SnakeGame({ onExit }) { setFood(randomFood(INITIAL_SNAKE)); setScore(0); setGameOver(false); + setIsRunning(false); } return (
-
-
-

Snake

-

High score: {readStorage(STORAGE_KEYS.gameScores, { snake: 0 }).snake ?? 0}

-
- +
+

Snake

+

+ High score: {readStorage(STORAGE_KEYS.gameScores, { snake: 0 }).snake ?? 0} +

+

+ Glide through the grid, eat the food, and keep your streak alive. +

+
+
+
+ {gameOver ? "You lose!" : isRunning ? "Stay sharp and keep climbing." : "Press start when you're ready."} +
+

Score: {score}

{gameOver ? (
- Ready to get back to it?
+ ) : !isRunning ? ( +
+ Use arrow keys or WASD + +
) : ( Use arrow keys or WASD )} diff --git a/client/src/components/games/TicTacToeGame.jsx b/client/src/components/games/TicTacToeGame.jsx index 5409353..8d1436b 100644 --- a/client/src/components/games/TicTacToeGame.jsx +++ b/client/src/components/games/TicTacToeGame.jsx @@ -21,25 +21,43 @@ function getWinner(board) { return board.every(Boolean) ? "draw" : null; } -function minimax(board, isMaximizing) { - const winner = getWinner(board); - if (winner === "O") return { score: 1 }; - if (winner === "X") return { score: -1 }; - if (winner === "draw") return { score: 0 }; +function findLineMove(board, marker) { + for (const [a, b, c] of lines) { + const values = [board[a], board[b], board[c]]; + const markerCount = values.filter((value) => value === marker).length; + const emptyIndex = [a, b, c].find((index) => !board[index]); + if (markerCount === 2 && emptyIndex !== undefined) { + return emptyIndex; + } + } + return null; +} - const moves = []; - board.forEach((cell, index) => { - if (!cell) { - const nextBoard = [...board]; - nextBoard[index] = isMaximizing ? "O" : "X"; - const result = minimax(nextBoard, !isMaximizing); - moves.push({ index, score: result.score }); +function chooseAiMove(board) { + const winningMove = findLineMove(board, "O"); + if (winningMove !== null) { + return winningMove; + } + + const shouldBlock = Math.random() > 0.35; + if (shouldBlock) { + const blockingMove = findLineMove(board, "X"); + if (blockingMove !== null) { + return blockingMove; } - }); + } + + const preferredMoves = [4, 0, 2, 6, 8, 1, 3, 5, 7]; + const availableMoves = preferredMoves.filter((index) => !board[index]); + if (!availableMoves.length) { + return undefined; + } + + if (Math.random() > 0.55) { + return availableMoves[0]; + } - return isMaximizing - ? moves.reduce((best, move) => (move.score > best.score ? move : best), { score: -Infinity }) - : moves.reduce((best, move) => (move.score < best.score ? move : best), { score: Infinity }); + return availableMoves[Math.floor(Math.random() * availableMoves.length)]; } export default function TicTacToeGame({ onExit }) { @@ -68,7 +86,7 @@ export default function TicTacToeGame({ onExit }) { return; } - const aiMove = minimax(nextBoard, true).index; + const aiMove = chooseAiMove(nextBoard); if (aiMove !== undefined) { nextBoard[aiMove] = "O"; setBoard([...nextBoard]); @@ -76,16 +94,28 @@ export default function TicTacToeGame({ onExit }) { } } + const resultMessage = winner + ? winner === "draw" + ? "Draw! That round was close." + : mode === "ai" + ? winner === "X" + ? "You win!" + : "The computer wins." + : `${winner} wins!` + : null; + return (
-
-
-

Tic Tac Toe

-

Perfect-play AI or pass-and-play on the same screen.

-
- +
+

Tic Tac Toe

+

+ Take on the AI or pass and play on the same screen. +

+
-
-

- {winner ? (winner === "draw" ? "Draw! Nicely matched." : `${winner} wins!`) : `Turn: ${turn}`} -

+
+
+ {resultMessage ?? (mode === "ai" ? "Beat the AI or force a draw." : `Turn: ${turn}`)} +
+
+
diff --git a/client/src/components/games/UnoGame.jsx b/client/src/components/games/UnoGame.jsx index ca036e6..58eea3c 100644 --- a/client/src/components/games/UnoGame.jsx +++ b/client/src/components/games/UnoGame.jsx @@ -5,6 +5,14 @@ const UNO_COLORS = ["red", "yellow", "green", "blue"]; const ACTION_TYPES = ["skip", "reverse", "draw2"]; const AI_TURN_DELAY_MS = 5000; +const CARD_STYLES = { + red: { background: "#f7d9d3", border: "#df9a8b", text: "#7f3c2e" }, + yellow: { background: "#f8efc9", border: "#d8bc61", text: "#7a5a00" }, + green: { background: "#d9f0dd", border: "#8cc39a", text: "#28583a" }, + blue: { background: "#dbe8fb", border: "#92b3e3", text: "#284a7a" }, + wild: { background: "#ebe4da", border: "#bba997", text: "#5d4d41" } +}; + function shuffle(deck) { const next = [...deck]; for (let index = next.length - 1; index > 0; index -= 1) { @@ -61,6 +69,10 @@ function cardLabel(card) { return `${card.color} ${card.type}`; } +function getCardTheme(card) { + return CARD_STYLES[card.color] ?? CARD_STYLES.wild; +} + function cloneState(state) { return { ...state, @@ -360,28 +372,57 @@ export default function UnoGame({ onExit }) { const topCard = getTopCard(state); const activeColor = getActiveColor(state); + const currentPlayerLabel = state.currentPlayer === 0 ? "Your turn" : `Player ${state.currentPlayer + 1}'s turn`; + const winnerLabel = + winner === -1 ? null : winner === 0 ? "You win the round!" : `Player ${winner + 1} wins the round.`; return (
-
-
-

UNO

-

Single-player vs two AI opponents with slower turns and hidden opponent hands.

-
- +
+

UNO

+

+ Single-player vs two AI opponents with slower turns and hidden opponent hands. +

+
-
-