diff --git a/LICENSE.md b/LICENSE.md index 9965b0865..1673a6c0c 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,21 +1,3 @@ MIT License -Copyright (c) 2019 Jacobo Martínez - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +Copyright (c) 2023 Benjamin Woolston diff --git a/README.md b/README.md index 2028d8528..4854e7c69 100644 --- a/README.md +++ b/README.md @@ -1,360 +1,27 @@ -# Simplefolio ⚡️ [![GitHub](https://img.shields.io/github/license/cobiwave/simplefolio?color=blue)](https://github.com/cobiwave/simplefolio/blob/master/LICENSE.md) ![GitHub stars](https://img.shields.io/github/stars/cobiwave/simplefolio) ![GitHub forks](https://img.shields.io/github/forks/cobiwave/simplefolio) +# Benjamin Woolston — Portfolio & Extras -## A minimal portfolio template for Developers! +This repo powers the portfolio as well as small side pages that live on subpaths. -

- Simplefolio -
-

- -## Features - -⚡️ Modern UI Design + Reveal Animations\ -⚡️ One Page Layout\ -⚡️ Styled with Bootstrap v4.3 + Custom SCSS\ -⚡️ Fully Responsive\ -⚡️ Valid HTML5 & CSS3\ -⚡️ Optimized with Parcel\ -⚡️ Well organized documentation - -To view the demo: **[click here](https://the-simplefolio.netlify.app/)** - ---- - -## Why do you need a portfolio? ☝️ - -- Professional way to showcase your work -- Increases your visibility and online presence -- Shows you’re more than just a resume - -## Getting Started 🚀 - -These instructions will get you a copy of the project up and running on your local machine for development and testing purposes. See deployment for notes on how to deploy the project on a live system. - -### Prerequisites 📋 - -You'll need [Git](https://git-scm.com) and [Node.js](https://nodejs.org/en/download/) (which comes with [NPM](http://npmjs.com)) installed on your computer. - -``` -node@v16.4.2 or higher -npm@7.18.1 or higher -git@2.30.1 or higher -``` - -Also, you can use [Yarn](https://yarnpkg.com/) instead of NPM ☝️ - -``` -yarn@v1.22.10 or higher -``` - ---- - -## How To Use 🔧 - -From your command line, first clone Simplefolio: +## Develop locally ```bash -# Clone the repository -$ git clone https://github.com/cobiwave/simplefolio - -# Move into the repository -$ cd simplefolio - -# Remove the current origin repository -$ git remote remove origin -``` - -After that, you can install the dependencies either using NPM or Yarn. - -Using NPM: Simply run the below commands. - -```bash -# 2022 Update - Fix Dependencies -$ npm audit fix -$ npm i @parcel/transformer-sass - -# Install dependencies -$ npm install - -# Start the development server -$ npm start +npm install +npm run start ``` -Using Yarn: Be aware of that you'll need to delete the `package-lock.json` file before executing the below commands. +Parcel will serve every page that sits under `src/`. The new Wooly Walking Challenge now lives at `/ww/` (visit `http://localhost:1234/ww/index.html` while running the dev server). The old `/w/` address simply redirects across. -```bash -# Install dependencies -$ yarn +## Wooly Walking Challenge 2025 -# Start the development server -$ yarn start -``` +- **Login**: usernames e.g. `ben_woolston`, default password `Password1`. +- **Data**: stored in the browser via `localStorage`. Use the backup/export buttons to move data between devices. +- **Dates**: challenge runs 6 Oct – 21 Dec 2025 with a stealth phase starting 7 Dec. +- **Build output**: `npm run build` will generate `/dist/ww/index.html`. Deploy that folder under the `ww` subpath (for example `woolston.dev/ww/`). -**NOTE**: -If your run into issues installing the dependencies with NPM, use this below command: +## Build ```bash -# Install dependencies with all permissions -$ sudo npm install --unsafe-perm=true --allow-root +npm run build ``` -Once your server has started, go to this url `http://localhost:1234/` to see the portfolio locally. It should look like the below screenshot. - -

- Simplefolio -

- ---- - -## Template Instructions: - -### Step 1 - STRUCTURE - -Go to `/src/index.html` and put your information, there are 5 sections: - -### (1) Hero Section - -- On `.hero-title`, put your custom portfolio title. -- On `.hero-cta`, put your custom button label. - -```html - -
-
-

- Hi, my name is Your Name -
- I'm the Unknown Developer. -

-

- - Know more - -

-
-
- -``` - -### (2) About Section - -- On `` tag, fill the `src` property with your profile picture path, your picture must be located inside `/src/assets/` folder. -- On `

` tag with class name `.about-wrapper__info-text`, include information about you, I recommend to put 2 paragraphs in order to work well and a maximum of 3 paragraphs. -- On last `` tag, include your CV (.pdf) path on `href` property, your resume CV must be located inside `/src/assets/` folder. - -```html - -

-
-

About me

-
-
-
- -``` - -### (3) Projects Section - -- Each project lives inside a `row`. -- On `

` tag with class name `.project-wrapper__text-title`, include your project title. -- On `

` tag with `loremp ipsum` text, include your project description. -- On first `` tag, put your project url on `href` property. -- On second `` tag, put your project repository url on `href` property. - ---- - -- Inside `

` tag with class name `.project-wrapper__image`, put your project image url on the `src` of the `` and put again your project url in the `href` property of the `` tag. -- Recommended size for project image (1366 x 767), your project image must be located inside `/src/assets/` folder. - -```html - -
- ... - - - - ... -
-``` - -### (4) Contact Section - -- On `

` tag with class name `.contact-wrapper__text`, include some custom call-to-action message. -- On `` tag, put your email address on `href` property. - -```html - -

- -
- -``` - -### (5) Footer Section - -- Put your Social Media URL on each `href` attribute of the `` tags. -- If you an additional Social Media account different than Twitter, Linkedin or GitHub, then go to [Font Awesome Icons](https://fontawesome.com/v4.7.0/icons/) and search for the icon's class name you are looking. -- You can delete or add as many `` tags your want. - -```html - -``` - -### Step 2 - STYLES - -Change the color theme of the website - (choose 2 colors to create a gradient) - -Go to `/src/sass/abstracts/_variables.scss` and only change the values for this variables `$main-color` and `$secondary-color` with your prefered HEX color. -If you want to get some gradients inspiration I highly recommend you to check this website [UI Gradient](https://uigradients.com/#BrightVault) - -```scss -// Default values -$main-color: #02aab0; -$secondary-color: #00cdac; -``` - ---- - -## Deployment 📦 - -Once you finish your setup. You need to put your website online! - -I highly recommend to use [Netlify](https://netlify.com) because it is super easy. - -## Others versions 👥 - -[Gatsby Simplefolio](https://github.com/cobiwave/gatsby-simplefolio) by [Jacobo Martinez](https://github.com/cobiwave)\ -[Ember.js Simplefolio](https://github.com/sernadesigns/simplefolio-ember) by [Michael Serna](https://github.com/sernadesigns) - -## Technologies used 🛠️ - -- [Parcel](https://parceljs.org/) - Bundler -- [Bootstrap 4](https://getbootstrap.com/docs/4.3/getting-started/introduction/) - Frontend component library -- [Sass](https://sass-lang.com/documentation) - CSS extension language -- [ScrollReveal.js](https://scrollrevealjs.org/) - JavaScript library -- [Tilt.js](https://gijsroge.github.io/tilt.js/) - JavaScript tiny parallax library - -## Authors - -- **Jacobo Martinez** - [https://github.com/cobiwave](https://github.com/cobiwave) - -## Status - -[![Netlify Status](https://api.netlify.com/api/v1/badges/3a029bfd-575c-41e5-8249-c864d482c2e5/deploy-status)](https://app.netlify.com/sites/the-simplefolio/deploys) - -## License 📄 - -This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details - -## Acknowledgments 🎁 - -I was motivated to create this project because I wanted to contribute on something useful for the dev community, thanks to [ZTM Community](https://github.com/zero-to-mastery) and [Andrei](https://github.com/aneagoie) +The build command bundles `index.html`, `projects.html`, and `w/index.html`. diff --git a/package-lock.json b/package-lock.json index 00127bf93..36c2a4f57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ }, "devDependencies": { "@parcel/transformer-sass": "^2.8.2", - "parcel": "^2.8.2", + "parcel": "^2.0.0", "prettier": "^2.8.1" } }, diff --git a/package.json b/package.json index 90b29e362..03d7b7d69 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "description": "A clean, beautiful and responsive portfolio template for Developers!", "source": "src/index.html", "scripts": { - "start": "parcel", - "build": "parcel build" + "start": "parcel src/index.html src/projects.html src/ww/index.html src/w/index.html", + "build": "parcel build src/index.html src/projects.html src/ww/index.html src/w/index.html" }, "repository": { "type": "git", @@ -21,7 +21,7 @@ "homepage": "https://github.com/cobiwave/simplefolio#readme", "devDependencies": { "@parcel/transformer-sass": "^2.8.2", - "parcel": "^2.8.2", + "parcel": "^2.0.0", "prettier": "^2.8.1" }, "dependencies": { diff --git a/project3.jpg b/project3.jpg new file mode 100644 index 000000000..c326795f0 Binary files /dev/null and b/project3.jpg differ diff --git a/resume.pdf b/resume.pdf new file mode 100644 index 000000000..774c2ea70 Binary files /dev/null and b/resume.pdf differ diff --git a/src/assets/RESUME.pdf b/src/assets/RESUME.pdf new file mode 100644 index 000000000..774c2ea70 Binary files /dev/null and b/src/assets/RESUME.pdf differ diff --git a/src/assets/Resume.pdf b/src/assets/Resume.pdf new file mode 100644 index 000000000..774c2ea70 Binary files /dev/null and b/src/assets/Resume.pdf differ diff --git a/src/assets/favicon.png b/src/assets/favicon.png deleted file mode 100755 index 123aacfe4..000000000 Binary files a/src/assets/favicon.png and /dev/null differ diff --git a/src/assets/favicon.svg b/src/assets/favicon.svg new file mode 100644 index 000000000..2f6d4b7ac --- /dev/null +++ b/src/assets/favicon.svg @@ -0,0 +1,7 @@ + + + + B + Ben + + diff --git a/src/assets/profile.jpg b/src/assets/profile.jpg index b3425b1fe..69e67be88 100755 Binary files a/src/assets/profile.jpg and b/src/assets/profile.jpg differ diff --git a/src/assets/project.jpg b/src/assets/project.jpg index a4b8119ff..7a9032c75 100755 Binary files a/src/assets/project.jpg and b/src/assets/project.jpg differ diff --git a/src/assets/project1.jpg b/src/assets/project1.jpg new file mode 100644 index 000000000..3eb64b151 Binary files /dev/null and b/src/assets/project1.jpg differ diff --git a/src/assets/project2.jpg b/src/assets/project2.jpg new file mode 100644 index 000000000..964c4590b Binary files /dev/null and b/src/assets/project2.jpg differ diff --git a/src/assets/project3.jpg b/src/assets/project3.jpg new file mode 100644 index 000000000..c326795f0 Binary files /dev/null and b/src/assets/project3.jpg differ diff --git a/src/assets/resume.pdf b/src/assets/resume.pdf old mode 100755 new mode 100644 index dbf091df9..774c2ea70 Binary files a/src/assets/resume.pdf and b/src/assets/resume.pdf differ diff --git a/src/index.html b/src/index.html index 9d1ed5a94..1e5eb9221 100755 --- a/src/index.html +++ b/src/index.html @@ -1,345 +1,44 @@ - - - - - - - - - - [Your name here] | Developer - - - - - - - - - - - - - - - -
- - -
-
-

- Hi, my name is Your Name -
- I'm the Unknown Developer. -

-

- Know more -

-
-
- - - -
-
-

About me

-
-
-
- Profile Image -
-
-
-
-

- This is where you can describe about yourself. The more you - describe about yourself, the more chances you have! -

-

- Extra Information about you! like hobbies and your goals. -

- - - View Resume - - -
-
-
-
-
- - - -
-
-
-

Projects

- - -
-
-
-

Project Title 0

-
-

- Describe the project being very specific, you can use the Twitter standard: no more than 280 characters: - complement the information: the skills learned or reinforced in its realization and how you faced it, - prove to be proactive in the search for solutions. -

-
- - See Live - - - Source Code - -
-
-
- -
-
- - - -
-
-
-

Project Title 1

-
-

- Demonstrate in this description the skills of a programmer: such as having commitment, - having perseverance and accepting alternative solutions. Remember that being a portfolio you are not selling the project, - you are selling yourself, it reflects the resources used: Frameworks, libraries, platforms, etc. -

-
- - See Live - - - Source Code - -
-
-
- -
-
- - - -
-
-
-

Project Title 2

-
-

- If the project was collaborative, reflect it in this description, that will demonstrate communication and/or leadership skills. - Additionally, if you made use of the mastery of a second language, it will reflect on you professionalism. -

-
- - See Live - - - Source Code - -
-
-
- -
-
- -
-
-
- - - -
-
-

Contact

-
-

[Put your call to action here]

- Call to Action -
-
-
- - - - - - - - + + + + + + Ben Woolston + + + + + + + +

Ben Woolston

+ +

I am a Senior Engineer at Telstra. I deploy fast, scalable solutions like RCS messaging.

+ +

I grew up surrounded by hacking, ebikes, drones and videogames. At 18, I decided I'd had enough of the games and left to study Electrical Engineering and Finance at the University of Queensland in Brisbane. Now I work with software.

+ +

I like to find flow states wherever I can - I lift weights, play sports (tennis, pickleball, squash), competitively race drones, meditate, run, and am trying (mainly struggling) to learn to play guitar.

+ +

I'm starting to track the books I read here.

+ +

+ Projects
+ Email
+ Medium +

+ + diff --git a/src/projects.html b/src/projects.html new file mode 100644 index 000000000..1e02f1915 --- /dev/null +++ b/src/projects.html @@ -0,0 +1,57 @@ + + + + + + + Projects — Ben Woolston + + + +

Projects

+ +

← Home

+ +

Selected things I’ve built to give people a little more leverage.

+ +

T-Shirt Extravaganza

+

+ I sent an email to every startup company in the world asking for a free T-Shirt.

+ To do this I used several data scraping tools like Scrapy & Selenium, as well as Google Bard Exploitation with fifty Google accounts to find company names and emails of over 5000 startups. The results are I received over 90 T-Shirts, 10 Water Bottles, 17 booklets and hundreds of stickers!

+ At peak, I hit Bard with >1500 requests a minute. +

+ T-Shirt Extravaganza + +

Swyftx: Boosting Team Efficiency

+

+ At Swyftx, my manager wanted me to increase team efficiency, so I created Python macros in my free time to speed up my workflow and ended up making my team 10% more efficient, bringing us to the #1/14 position in the team efficiency chart. +

+ Swyftx project dashboard + +

Cannology

+

+ I started my first business at 15, and I'm now the owner of the business Cannology. We are now sold in over 50 pharmacies across Australia.

+ The business takes up a small part of each week and is something I love building in the background. I’m currently emailing every chemist in Australia to become a stockist, all automated with Python & ChatGPT. Visit Website. +

+ Cannology product display + + + diff --git a/src/sass/abstracts/_variables.scss b/src/sass/abstracts/_variables.scss index 6cbe5c3f1..df995b332 100755 --- a/src/sass/abstracts/_variables.scss +++ b/src/sass/abstracts/_variables.scss @@ -1,6 +1,6 @@ // COLORS -$primary-color: #02aab0; -$secondary-color: #00cdac; +$primary-color: #4493c5; +$secondary-color: #44A2BC; $white-color: #fff; diff --git a/src/w/index.html b/src/w/index.html new file mode 100644 index 000000000..74ecad168 --- /dev/null +++ b/src/w/index.html @@ -0,0 +1,33 @@ + + + + + Redirecting… + + + + + + +

Redirecting to woolston.dev/ww/

+ + + diff --git a/src/ww/challenge-badge.svg b/src/ww/challenge-badge.svg new file mode 100644 index 000000000..ba9f5d314 --- /dev/null +++ b/src/ww/challenge-badge.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + 🏃 + Wooly + + diff --git a/src/ww/index.html b/src/ww/index.html new file mode 100644 index 000000000..8453c259a --- /dev/null +++ b/src/ww/index.html @@ -0,0 +1,243 @@ + + + + + + Wooly Walking Challenge 2025 + + + + + + + + + +
+
+
+
+
+ +
+
+
+ Dark mode + +
+

🏆 Third Annual Wooly Walking Challenge

+

Walk proud. Walk hard. Claim the crown.

+

+ The family step-off returns from 6 October to 21 December 2025. + Log your steps, track the leaderboard, and keep your totals secret once the stealth phase begins. +

+ +
+
+ +
+
+
+

Family login

+

Use your Wooly Walking username. Passwords start as Password1.

+
+
+ + + + +
+ + +
+ + + +
+
+ + +
+ + + + + diff --git a/src/ww/index.js b/src/ww/index.js new file mode 100644 index 000000000..e528ca738 --- /dev/null +++ b/src/ww/index.js @@ -0,0 +1,778 @@ +const PARTICIPANTS = [ + { username: "ben_woolston", name: "Ben Woolston", icon: "🧠" }, + { username: "andre", name: "Andre", icon: "⚡" }, + { username: "anna_woolston", name: "Anna Woolston", icon: "🌸" }, + { username: "annette_mcgrath", name: "Annette McGrath", icon: "🎯" }, + { username: "con_woolston", name: "Con Woolston", icon: "🦭" }, + { username: "james_senanayake", name: "James Senanayake", icon: "🛰️" }, + { username: "jo_woolston", name: "Jo Woolston", icon: "🐔" }, + { username: "krista_woolston", name: "Krista Woolston", icon: "🌴" } +]; + +const DEFAULT_PASSWORD = "Password1"; +const STORAGE_KEY = "wooly-walking-2025"; +const CHALLENGE = { + start: new Date("2025-10-06T00:00:00"), + end: new Date("2025-12-21T23:59:59"), + stealthStart: new Date("2025-12-07T00:00:00") +}; + +const loginForm = document.querySelector("#login-form"); +const usernameField = document.querySelector("#username"); +const passwordField = document.querySelector("#password"); +const authError = document.querySelector("#auth-error"); +const authPanel = document.querySelector("#auth-panel"); +const dashboard = document.querySelector("#dashboard"); +const dashboardTitle = document.querySelector("#dashboard-title"); +const phaseIndicator = document.querySelector("#phase-indicator"); +const daysLeft = document.querySelector("#days-left"); +const personalTotal = document.querySelector("#personal-total"); +const personalRank = document.querySelector("#personal-rank"); +const personalBest = document.querySelector("#personal-best"); +const personalBestDate = document.querySelector("#personal-best-date"); +const personalStreak = document.querySelector("#personal-streak"); +const personalBadge = document.querySelector("#personal-badge"); +const personalBadgeNote = document.querySelector("#personal-badge-note"); +const leaderboardBody = document.querySelector("#leaderboard-body"); +const leaderboardCaption = document.querySelector("#leaderboard-caption"); +const motivationCopy = document.querySelector("#motivation-copy"); +const weeksContainer = document.querySelector("#weeks-container"); +const collapseWeeksBtn = document.querySelector("#collapse-weeks"); +const expandWeeksBtn = document.querySelector("#expand-weeks"); +const stealthPill = document.querySelector("#stealth-pill"); +const downloadBackupBtn = document.querySelector("#download-backup"); +const importBackupInput = document.querySelector("#import-backup"); +const passwordToggle = document.querySelector(".auth-form__toggle"); +const themeToggle = document.querySelector("#theme-toggle"); +const themeToggleLabel = document.querySelector("#theme-toggle-label"); +const progressChartCanvas = document.querySelector("#progress-chart"); +const chartAverage = document.querySelector("#chart-average"); + +let appState = { + activeUser: null, + data: ensureInitialData() +}; + +const challengeDates = buildChallengeDates(CHALLENGE.start, CHALLENGE.end); +const weeks = chunkDatesByWeek(challengeDates); +const WEEKLY_GOAL = 70000; +const THEME_KEY = "wooly-walking-theme"; +let progressChart = null; + +// Prefill username if remembered +const lastUser = appState.data.meta.lastUser; +if (lastUser) { + usernameField.value = lastUser; +} + +usernameField.focus(); + +passwordToggle.addEventListener("click", () => { + const current = passwordField.getAttribute("type"); + passwordField.setAttribute("type", current === "password" ? "text" : "password"); +}); + +initialiseTheme(); + +themeToggle?.addEventListener("click", () => { + const current = document.body.getAttribute("data-theme") === "light" ? "light" : "dark"; + const next = current === "light" ? "dark" : "light"; + applyTheme(next); +}); + +loginForm.addEventListener("submit", (event) => { + event.preventDefault(); + const username = usernameField.value.trim().toLowerCase(); + const password = passwordField.value; + + const participant = PARTICIPANTS.find((person) => person.username === username); + if (!participant) { + return showAuthError("Unknown username. Try again."); + } + + if (password !== DEFAULT_PASSWORD) { + return showAuthError("Incorrect password. Give it another go."); + } + + clearAuthError(); + setActiveUser(username); + appState.data.meta.lastUser = username; + saveData(appState.data); + renderDashboard(); +}); + +collapseWeeksBtn.addEventListener("click", () => { + document.querySelectorAll(".week-card").forEach((card) => { + card.classList.remove("is-open"); + }); +}); + +expandWeeksBtn.addEventListener("click", () => { + document.querySelectorAll(".week-card").forEach((card) => { + card.classList.add("is-open"); + }); +}); + +downloadBackupBtn.addEventListener("click", () => { + const payload = JSON.stringify(appState.data, null, 2); + const blob = new Blob([payload], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = "wooly-walking-progress.json"; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); +}); + +importBackupInput.addEventListener("change", async (event) => { + const file = event.target.files?.[0]; + if (!file) return; + try { + const text = await file.text(); + const parsed = JSON.parse(text); + if (!parsed || typeof parsed !== "object" || !parsed.participants) { + throw new Error("Invalid file"); + } + syncImportedData(parsed); + saveData(appState.data); + renderDashboard(); + importBackupInput.value = ""; + } catch (error) { + alert("Import failed. Please make sure you selected a valid backup file."); + } +}); + +function ensureInitialData() { + const stored = window.localStorage.getItem(STORAGE_KEY); + if (stored) { + try { + const parsed = JSON.parse(stored); + return normaliseData(parsed); + } catch (error) { + console.warn("Failed to parse stored data, resetting."); + } + } + const empty = createEmptyDataShape(); + saveData(empty); + return empty; +} + +function normaliseData(raw) { + const template = createEmptyDataShape(); + const output = { + participants: {}, + meta: { lastUser: raw.meta?.lastUser ?? template.meta.lastUser } + }; + + PARTICIPANTS.forEach((person) => { + const incoming = raw.participants?.[person.username]; + const base = template.participants[person.username]; + output.participants[person.username] = { + dailySteps: { ...base.dailySteps, ...(incoming?.dailySteps || {}) }, + notes: incoming?.notes || base.notes + }; + }); + + return output; +} + +function createEmptyDataShape() { + const participants = {}; + PARTICIPANTS.forEach((person) => { + const dailySteps = {}; + challengeDates.forEach((date) => { + dailySteps[date.iso] = 0; + }); + participants[person.username] = { dailySteps, notes: "" }; + }); + return { participants, meta: { lastUser: "" } }; +} + +function saveData(data) { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); +} + +function setActiveUser(username) { + appState.activeUser = username; + authPanel.hidden = true; + dashboard.hidden = false; + dashboard.scrollIntoView({ behavior: "smooth" }); +} + +function renderDashboard() { + if (!appState.activeUser) return; + + const participant = PARTICIPANTS.find((person) => person.username === appState.activeUser); + dashboardTitle.textContent = `${participant.icon} ${participant.name}`; + + const now = new Date(); + const phase = resolvePhase(now); + phaseIndicator.textContent = phase.label; + daysLeft.textContent = String(Math.max(0, phase.daysRemaining)); + motivationCopy.textContent = phase.message; + leaderboardCaption.textContent = phase.leaderboardCopy; + stealthPill.hidden = !phase.stealth; + + renderMomentumCards(); + renderLeaderboard(phase); + renderWeeklyChart(); + renderWeeks(); +} + +function renderMomentumCards() { + const userData = appState.data.participants[appState.activeUser]; + const totals = computeTotals(userData.dailySteps); + personalTotal.textContent = formatNumber(totals.totalSteps); + personalBest.textContent = formatNumber(totals.bestDaySteps); + personalBestDate.textContent = totals.bestDayLabel || "—"; + personalStreak.textContent = String(totals.currentStreak); + if (chartAverage) { + chartAverage.textContent = formatNumber(Math.round(totals.weeklyAverage)); + } + + const rank = computeRank(appState.activeUser); + personalRank.textContent = `Ranked ${rank.position} of ${rank.total}`; + + const badge = resolveBadge(totals); + personalBadge.textContent = badge.title; + personalBadgeNote.textContent = badge.caption; +} + +function renderLeaderboard(phase) { + const everyone = PARTICIPANTS.map((person) => { + const dailySteps = appState.data.participants[person.username].dailySteps; + const totals = computeTotals(dailySteps); + return { + username: person.username, + name: person.name, + icon: person.icon, + totalSteps: totals.totalSteps, + weeklyAvg: totals.weeklyAverage, + bestDay: totals.bestDaySteps + }; + }).sort((a, b) => b.totalSteps - a.totalSteps); + + const maxSteps = everyone[0]?.totalSteps || 1; + leaderboardBody.innerHTML = ""; + + everyone.forEach((record, index) => { + const row = document.createElement("tr"); + if (record.username === appState.activeUser) { + row.classList.add("is-self"); + } + + const showTotals = !phase.stealth || record.username === appState.activeUser || phase.revealed; + const totalCellContent = showTotals ? formatNumber(record.totalSteps) : "— hidden —"; + + const paceValue = showTotals ? `${formatNumber(Math.round(record.weeklyAvg))} / wk` : "In stealth"; + const barPercent = Math.max(4, Math.round((record.totalSteps / maxSteps) * 100)); + + row.innerHTML = ` + #${index + 1} + ${record.icon} ${record.name} + ${totalCellContent} + ${paceValue} + `; + + leaderboardBody.appendChild(row); + }); +} + +function renderWeeks() { + weeksContainer.innerHTML = ""; + const userData = appState.data.participants[appState.activeUser]; + const todayIso = dateToIso(new Date()); + + weeks.forEach((week, index) => { + const card = document.createElement("article"); + card.className = "week-card"; + if (index === 0) { + card.classList.add("is-open"); + } + + const weekTotal = week.dates.reduce((sum, day) => sum + (userData.dailySteps[day.iso] || 0), 0); + + const header = document.createElement("div"); + header.className = "week-card__header"; + header.innerHTML = ` +
+

Week ${index + 1}

+ ${week.label} +
+ ${formatNumber(weekTotal)} steps + `; + + header.addEventListener("click", () => { + card.classList.toggle("is-open"); + }); + + const body = document.createElement("div"); + body.className = "week-card__body"; + + const table = document.createElement("table"); + table.className = "week-card__days"; + table.innerHTML = ` + + + Day + Steps + + + + + + Week total + ${formatNumber(weekTotal)} steps + + + `; + + const tbody = table.querySelector("tbody"); + + week.dates.forEach((day) => { + const tr = document.createElement("tr"); + + const label = document.createElement("td"); + label.className = "week-card__day-label"; + const isToday = day.iso === todayIso; + if (isToday) { + label.classList.add("is-today"); + } + label.innerHTML = `${day.short}${day.long}`; + + const inputCell = document.createElement("td"); + const input = document.createElement("input"); + input.type = "number"; + input.min = "0"; + input.step = "1"; + input.inputMode = "numeric"; + input.value = userData.dailySteps[day.iso] || ""; + input.dataset.date = day.iso; + input.dataset.weekIndex = String(index); + + input.addEventListener("change", (event) => { + const raw = event.target.value; + const parsed = Math.max(0, parseInt(raw, 10) || 0); + event.target.value = parsed ? String(parsed) : ""; + userData.dailySteps[day.iso] = parsed; + saveData(appState.data); + updateWeekTotals(index); + renderMomentumCards(); + renderLeaderboard(resolvePhase(new Date())); + renderWeeklyChart(); + }); + + inputCell.appendChild(input); + tr.appendChild(label); + tr.appendChild(inputCell); + tbody.appendChild(tr); + }); + + body.appendChild(table); + card.appendChild(header); + card.appendChild(body); + weeksContainer.appendChild(card); + }); +} + +function updateWeekTotals(weekIndex) { + const userData = appState.data.participants[appState.activeUser]; + const week = weeks[weekIndex]; + const total = week.dates.reduce((sum, day) => sum + (userData.dailySteps[day.iso] || 0), 0); + const badge = document.querySelector(`[data-week-total="${weekIndex}"]`); + const footer = document.querySelector(`[data-week-total-footer="${weekIndex}"]`); + const label = `${formatNumber(total)} steps`; + if (badge) badge.textContent = label; + if (footer) footer.textContent = label; +} + +function computeTotals(dailySteps) { + let totalSteps = 0; + let bestDaySteps = 0; + let bestDayIso = ""; + let currentStreak = 0; + let streak = 0; + + const today = new Date(); + const todayIso = dateToIso(today); + + const days = challengeDates.map((day) => ({ ...day, steps: dailySteps[day.iso] || 0 })); + + days.forEach((day) => { + totalSteps += day.steps; + if (day.steps > bestDaySteps) { + bestDaySteps = day.steps; + bestDayIso = day.iso; + } + }); + + for (let i = days.length - 1; i >= 0; i -= 1) { + const day = days[i]; + if (day.steps > 0) { + streak += 1; + currentStreak = streak; + } else { + if (dateIsAfterIso(todayIso, day.iso)) { + break; + } + streak = 0; + } + } + + const completedDays = days.filter((day) => dateIsAfterIso(todayIso, day.iso) || day.iso === todayIso).length; + const weeksElapsed = Math.max(1, completedDays / 7); + + return { + totalSteps, + bestDaySteps, + bestDayLabel: bestDayIso ? readableDate(bestDayIso) : "", + currentStreak, + weeklyAverage: totalSteps / weeksElapsed + }; +} + +function computeRank(username) { + const everyone = PARTICIPANTS.map((person) => { + const totals = computeTotals(appState.data.participants[person.username].dailySteps); + return { username: person.username, total: totals.totalSteps }; + }).sort((a, b) => b.total - a.total); + + const position = Math.max(1, everyone.findIndex((entry) => entry.username === username) + 1); + return { position, total: everyone.length }; +} + +function resolveBadge(totals) { + if (totals.totalSteps >= 420000) { + return { title: "Crown Chaser", caption: "Walking royalty in the making." }; + } + if (totals.currentStreak >= 14) { + return { title: "Consistency Beast", caption: "14+ day streak — unstoppable." }; + } + if (totals.bestDaySteps >= 25000) { + return { title: "Power Surge", caption: "One monster day above 25k." }; + } + if (totals.totalSteps >= 210000) { + return { title: "Halfway Hero", caption: "You passed the halfway mark." }; + } + if (totals.bestDaySteps >= 15000) { + return { title: "Sprinter", caption: "Huge daily burst logged." }; + } + if (totals.totalSteps >= 70000) { + return { title: "On the Board", caption: "Seven days of 10k pace." }; + } + return { title: "Keep marching", caption: "Log steps to unlock your first badge." }; +} + +function resolvePhase(now) { + const beforeStart = now < CHALLENGE.start; + const afterEnd = now > CHALLENGE.end; + const inStealth = now >= CHALLENGE.stealthStart && now <= CHALLENGE.end; + + if (beforeStart) { + const days = Math.ceil((CHALLENGE.start - now) / (1000 * 60 * 60 * 24)); + return { + label: "Warm-up", + message: "Prep those calves. Countdown to the starting gun.", + leaderboardCopy: "Warm-up period. Totals will appear once the challenge kicks off.", + daysRemaining: Math.max(days, 0), + stealth: false, + revealed: false + }; + } + + if (afterEnd) { + return { + label: "Grand reveal", + message: "Time to crown the champion and grill the bottom two chefs.", + leaderboardCopy: "Final results unlocked. Congratulate (or heckle) accordingly.", + daysRemaining: 0, + stealth: false, + revealed: true + }; + } + + if (inStealth) { + const days = Math.ceil((CHALLENGE.end - now) / (1000 * 60 * 60 * 24)); + return { + label: "Stealth mode", + message: "Totals are hidden. Keep logging and keep them guessing.", + leaderboardCopy: "Stealth mode active — only your own totals are visible.", + daysRemaining: Math.max(days, 0), + stealth: true, + revealed: false + }; + } + + const days = Math.ceil((CHALLENGE.end - now) / (1000 * 60 * 60 * 24)); + return { + label: "Active battle", + message: "Clock those steps weekly. Top spot is there for the taking.", + leaderboardCopy: "Live totals update whenever someone logs their steps.", + daysRemaining: Math.max(days, 0), + stealth: false, + revealed: false + }; +} + +function buildChallengeDates(start, end) { + const dates = []; + const cursor = new Date(start); + while (cursor <= end) { + const iso = dateToIso(cursor); + dates.push({ + iso, + label: readableDate(iso), + short: cursor.toLocaleDateString(undefined, { weekday: "short" }), + long: cursor.toLocaleDateString(undefined, { month: "short", day: "numeric" }) + }); + cursor.setDate(cursor.getDate() + 1); + } + return dates; +} + +function chunkDatesByWeek(dates) { + const chunks = []; + for (let i = 0; i < dates.length; i += 7) { + const slice = dates.slice(i, i + 7); + const first = slice[0]; + const last = slice[slice.length - 1]; + const label = `${first.long} – ${last.long}`; + chunks.push({ dates: slice, label }); + } + return chunks; +} + +function formatNumber(value) { + return new Intl.NumberFormat().format(value); +} + +function dateToIso(date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function readableDate(isoString) { + const date = new Date(isoString); + return date.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }); +} + +function dateIsAfterIso(referenceIso, targetIso) { + return referenceIso > targetIso; +} + +function showAuthError(message) { + authError.textContent = message; +} + +function clearAuthError() { + authError.textContent = ""; +} + +function syncImportedData(parsed) { + const normalised = normaliseData(parsed); + appState.data = normalised; + if (appState.activeUser && !appState.data.participants[appState.activeUser]) { + appState.activeUser = null; + dashboard.hidden = true; + authPanel.hidden = false; + } +} + +function initialiseTheme() { + let stored = null; + try { + stored = window.localStorage.getItem(THEME_KEY); + } catch (error) { + stored = null; + } + let theme = stored === "light" || stored === "dark" ? stored : null; + if (!theme) { + const prefersLight = window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches; + theme = prefersLight ? "light" : "dark"; + } + applyTheme(theme); +} + +function applyTheme(theme) { + document.body.setAttribute("data-theme", theme); + try { + window.localStorage.setItem(THEME_KEY, theme); + } catch (error) { + // ignore storage issues + } + updateThemeToggle(theme); + if (appState.activeUser) { + renderWeeklyChart(); + } +} + +function updateThemeToggle(theme) { + if (!themeToggle) return; + themeToggle.dataset.state = theme; + themeToggle.setAttribute("aria-label", theme === "dark" ? "Switch to light mode" : "Switch to dark mode"); + themeToggle.setAttribute("aria-checked", theme === "light" ? "true" : "false"); + if (themeToggleLabel) { + themeToggleLabel.textContent = theme === "dark" ? "Dark mode" : "Light mode"; + } +} + +function renderWeeklyChart() { + if (!appState.activeUser || !progressChartCanvas || typeof Chart === "undefined") { + return; + } + + const ctx = progressChartCanvas.getContext("2d"); + if (!ctx) return; + + const computed = window.getComputedStyle(document.body); + const accent = computed.getPropertyValue("--accent").trim() || "#ff6a3d"; + const accentSoft = computed.getPropertyValue("--accent-soft").trim() || "rgba(255, 106, 61, 0.2)"; + const success = computed.getPropertyValue("--success").trim() || "#60f5d2"; + const textMuted = computed.getPropertyValue("--text-muted").trim() || "#a1a1aa"; + const textPrimary = computed.getPropertyValue("--text-primary").trim() || "#111827"; + const chartGrid = computed.getPropertyValue("--chart-grid").trim() || "rgba(160, 174, 192, 0.25)"; + const tooltipBg = computed.getPropertyValue("--chart-tooltip-bg").trim() || "rgba(8, 10, 26, 0.94)"; + const tooltipBorder = computed.getPropertyValue("--chart-tooltip-border").trim() || "rgba(255, 255, 255, 0.12)"; + const borderSoft = computed.getPropertyValue("--border-soft").trim() || "rgba(255, 255, 255, 0.08)"; + + const userData = appState.data.participants[appState.activeUser]; + const weekTotals = weeks.map((week) => week.dates.reduce((sum, day) => sum + (userData.dailySteps[day.iso] || 0), 0)); + const labels = weeks.map((_, index) => `W${index + 1}`); + const todayIso = dateToIso(new Date()); + const challengeStartIso = challengeDates[0]?.iso || todayIso; + + // Show weeks that have started (first day of week is today or earlier) + const revealedWeeks = weeks.map((week) => { + const weekStartIso = week.dates[0].iso; + return weekStartIso <= todayIso; + }); + + const showAllWeeks = todayIso < challengeStartIso; + const totalsForChart = weekTotals.map((total, index) => (showAllWeeks || revealedWeeks[index] ? total : null)); + const goalData = weekTotals.map((_, index) => (showAllWeeks || revealedWeeks[index] ? WEEKLY_GOAL : null)); + + const canvasHeight = progressChartCanvas.offsetHeight || progressChartCanvas.height || 260; + const gradient = ctx.createLinearGradient(0, 0, 0, canvasHeight); + gradient.addColorStop(0, accent); + gradient.addColorStop(1, accentSoft || accent); + + if (progressChart) { + progressChart.destroy(); + } + + progressChart = new Chart(ctx, { + type: "bar", + data: { + labels, + datasets: [ + { + type: "bar", + label: "Your steps", + data: totalsForChart, + backgroundColor: gradient, + borderRadius: 14, + borderSkipped: false, + maxBarThickness: 42 + }, + { + type: "line", + label: "Goal pace", + data: goalData, + borderColor: success, + borderWidth: 2, + borderDash: [6, 6], + pointBackgroundColor: success, + pointBorderColor: success, + pointRadius: 0, + pointHoverRadius: 4, + tension: 0.35 + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { + mode: "index", + intersect: false + }, + plugins: { + legend: { + display: false + }, + tooltip: { + backgroundColor: tooltipBg, + borderColor: tooltipBorder, + borderWidth: 1, + titleColor: textPrimary, + bodyColor: textPrimary, + displayColors: false, + callbacks: { + title(context) { + return context[0]?.label || ""; + }, + label(context) { + const value = context.parsed.y; + if (value === null || Number.isNaN(value)) return ""; + return `${formatNumber(Math.round(value))} steps`; + } + } + } + }, + layout: { + padding: { + top: 12, + right: 16, + left: 8, + bottom: 0 + } + }, + scales: { + x: { + grid: { + display: false + }, + ticks: { + color: textMuted, + font: { + family: '"Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + weight: "600" + } + } + }, + y: { + beginAtZero: true, + grid: { + color: chartGrid, + borderDash: [6, 6], + drawBorder: false + }, + ticks: { + color: textMuted, + padding: 8, + font: { + family: '"Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' + }, + callback(value) { + const numeric = Number(value) || 0; + if (numeric >= 1000) { + return `${Math.round(numeric / 1000)}k`; + } + return formatNumber(numeric); + } + } + } + } + } + }); +} + +// Auto-render if a remembered user exists and the password form was bypassed earlier. +if (lastUser && PARTICIPANTS.some((person) => person.username === lastUser)) { + setActiveUser(lastUser); + renderDashboard(); +} diff --git a/src/ww/styles.scss b/src/ww/styles.scss new file mode 100644 index 000000000..b8d1bbcd5 --- /dev/null +++ b/src/ww/styles.scss @@ -0,0 +1,994 @@ +:root { + --radius: 28px; +} + +:root, +body[data-theme="dark"] { + --bg-1: #05040f; + --bg-2: #120c25; + --bg-card: rgba(14, 12, 28, 0.76); + --bg-card-strong: rgba(28, 22, 60, 0.82); + --border-soft: rgba(255, 255, 255, 0.08); + --accent: #ff6a3d; + --accent-strong: #ffb347; + --accent-soft: rgba(255, 106, 61, 0.2); + --text-primary: #f8f9ff; + --text-muted: rgba(248, 249, 255, 0.7); + --success: #60f5d2; + --warning: #ffe15d; + --danger: #ff4f6d; + --shadow: 0 30px 60px -40px rgba(0, 0, 0, 0.85); + --glass-sheen: rgba(255, 255, 255, 0.08); + --hero-highlight: rgba(255, 122, 102, 0.35); + --grid-color: rgba(255, 255, 255, 0.06); + --bg-glow: rgba(87, 36, 180, 0.26); + --meta-card-bg: rgba(255, 255, 255, 0.06); + --meta-card-accent: rgba(255, 106, 61, 0.2); + --momentum-card-bg: rgba(5, 4, 20, 0.7); + --momentum-card-border: rgba(255, 255, 255, 0.08); + --momentum-card-overlay: rgba(255, 255, 255, 0.15); + --accent-ring: rgba(255, 165, 0, 0.4); + --table-row-alt: rgba(255, 255, 255, 0.04); + --table-row-hover: rgba(255, 255, 255, 0.08); + --table-row-self: rgba(96, 245, 210, 0.12); + --pace-track: rgba(255, 255, 255, 0.12); + --stealth-bg: rgba(255, 228, 82, 0.14); + --stealth-border: rgba(255, 228, 82, 0.5); + --ghost-border: rgba(255, 255, 255, 0.24); + --ghost-hover: rgba(255, 255, 255, 0.08); + --week-card-bg: rgba(5, 4, 20, 0.75); + --week-card-border: rgba(255, 255, 255, 0.08); + --input-surface: rgba(10, 10, 20, 0.82); + --input-surface-focus: rgba(18, 18, 34, 0.92); + --input-border: rgba(255, 255, 255, 0.16); + --input-border-strong: rgba(255, 255, 255, 0.14); + --input-bg-muted: rgba(255, 255, 255, 0.05); + --input-bg-focus: rgba(255, 255, 255, 0.07); + --day-chip-bg: rgba(255, 255, 255, 0.08); + --day-chip-today-bg: rgba(96, 245, 210, 0.16); + --code-bg: rgba(255, 255, 255, 0.08); + --chart-grid: rgba(248, 249, 255, 0.16); + --chart-tooltip-bg: rgba(5, 7, 22, 0.94); + --chart-tooltip-border: rgba(255, 255, 255, 0.12); + --cta-shadow: 0 15px 35px -25px rgba(255, 106, 61, 0.9); + --cta-shadow-hover: 0 24px 40px -30px rgba(255, 106, 61, 0.9); + color-scheme: dark; +} + +body[data-theme="light"] { + --bg-1: #f5f7ff; + --bg-2: #dbeafe; + --bg-card: rgba(255, 255, 255, 0.9); + --bg-card-strong: rgba(255, 255, 255, 0.96); + --border-soft: rgba(15, 23, 42, 0.08); + --accent: #ea580c; + --accent-strong: #d97706; + --accent-soft: rgba(234, 88, 12, 0.16); + --text-primary: #0f172a; + --text-muted: rgba(15, 23, 42, 0.65); + --success: #0f766e; + --warning: #ca8a04; + --danger: #dc2626; + --shadow: 0 30px 60px -45px rgba(15, 23, 42, 0.35); + --glass-sheen: rgba(15, 23, 42, 0.08); + --hero-highlight: rgba(250, 204, 21, 0.28); + --grid-color: rgba(15, 23, 42, 0.12); + --bg-glow: rgba(59, 130, 246, 0.18); + --meta-card-bg: rgba(15, 23, 42, 0.06); + --meta-card-accent: rgba(234, 88, 12, 0.12); + --momentum-card-bg: rgba(255, 255, 255, 0.92); + --momentum-card-border: rgba(15, 23, 42, 0.08); + --momentum-card-overlay: rgba(148, 163, 184, 0.16); + --accent-ring: rgba(234, 179, 8, 0.45); + --table-row-alt: rgba(148, 163, 184, 0.16); + --table-row-hover: rgba(148, 163, 184, 0.24); + --table-row-self: rgba(14, 165, 233, 0.18); + --pace-track: rgba(148, 163, 184, 0.35); + --stealth-bg: rgba(234, 179, 8, 0.18); + --stealth-border: rgba(234, 179, 8, 0.45); + --ghost-border: rgba(15, 23, 42, 0.14); + --ghost-hover: rgba(15, 23, 42, 0.08); + --week-card-bg: rgba(255, 255, 255, 0.88); + --week-card-border: rgba(15, 23, 42, 0.08); + --input-surface: rgba(255, 255, 255, 0.94); + --input-surface-focus: rgba(255, 255, 255, 1); + --input-border: rgba(148, 163, 184, 0.45); + --input-border-strong: rgba(148, 163, 184, 0.45); + --input-bg-muted: rgba(148, 163, 184, 0.16); + --input-bg-focus: rgba(30, 64, 175, 0.12); + --day-chip-bg: rgba(148, 163, 184, 0.18); + --day-chip-today-bg: rgba(14, 165, 233, 0.26); + --code-bg: rgba(191, 219, 254, 0.6); + --chart-grid: rgba(148, 163, 184, 0.3); + --chart-tooltip-bg: rgba(255, 255, 255, 0.95); + --chart-tooltip-border: rgba(148, 163, 184, 0.3); + --cta-shadow: 0 18px 36px -26px rgba(234, 88, 12, 0.55); + --cta-shadow-hover: 0 26px 48px -28px rgba(234, 88, 12, 0.5); + color-scheme: light; +} + +* { + box-sizing: border-box; +} + +body.challenge-body { + margin: 0; + font-family: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: radial-gradient(circle at top, var(--bg-glow), transparent 55%), + linear-gradient(140deg, var(--bg-1) 0%, var(--bg-2) 70%); + color: var(--text-primary); + min-height: 100vh; + padding: 0 18px 80px; + position: relative; + transition: background 0.4s ease, color 0.3s ease; +} + +.backdrop { + position: fixed; + inset: 0; + overflow: hidden; + z-index: -1; +} + +.backdrop__grid { + position: absolute; + inset: 0; + background-image: radial-gradient(var(--grid-color) 1px, transparent 0); + background-size: 60px 60px; + opacity: 0.22; + transform: translateZ(0); +} + +.backdrop__orb { + position: absolute; + filter: blur(140px); + width: 420px; + height: 420px; + border-radius: 50%; + opacity: 0.45; + animation: float 22s linear infinite; +} + +.backdrop__orb--one { + background: #8132ff; + top: -120px; + left: -120px; +} + +.backdrop__orb--two { + background: #ff7136; + bottom: -160px; + right: -120px; + animation-delay: 4s; +} + +@keyframes float { + 0% { + transform: translate3d(0, 0, 0) scale(1); + } + 50% { + transform: translate3d(40px, -30px, 0) scale(1.06); + } + 100% { + transform: translate3d(0, 0, 0) scale(1); + } +} + +.challenge-hero { + max-width: 1080px; + margin: 0 auto; + padding: 120px 0 32px; +} + +.challenge-hero__inner { + background: linear-gradient(120deg, var(--glass-sheen), transparent); + border: 1px solid var(--border-soft); + border-radius: var(--radius); + padding: 40px; + box-shadow: var(--shadow); + position: relative; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.challenge-hero__inner::after { + content: ""; + position: absolute; + inset: 0; + background: radial-gradient(circle at top right, var(--hero-highlight), transparent 55%); + mix-blend-mode: screen; +} + +.theme-switcher { + position: absolute; + top: 24px; + right: 24px; + display: inline-flex; + align-items: center; + gap: 12px; + z-index: 10; +} + +.theme-switcher__label { + font-size: 0.75rem; + letter-spacing: 0.24em; + text-transform: uppercase; + color: var(--text-muted); + font-weight: 600; +} + +.theme-switcher__button { + position: relative; + width: 60px; + height: 32px; + border-radius: 999px; + border: 1px solid var(--border-soft); + background: var(--bg-card-strong); + padding: 4px 8px; + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 6px; + color: var(--text-muted); + cursor: pointer; + transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease; +} + +.theme-switcher__button:hover { + border-color: var(--accent); + box-shadow: 0 12px 28px -22px rgba(15, 23, 42, 0.6); +} + +body[data-theme="dark"] .theme-switcher__button:hover { + box-shadow: 0 12px 28px -22px rgba(0, 0, 0, 0.7); +} + +.theme-switcher__icon { + pointer-events: none; + font-size: 1.05rem; + line-height: 1; + opacity: 0.45; + transition: opacity 0.3s ease, transform 0.3s ease; +} + +.theme-switcher__button[data-state="light"] .theme-switcher__icon--sun, +.theme-switcher__button[data-state="dark"] .theme-switcher__icon--moon { + opacity: 0.95; + transform: translateY(-1px); +} + +.theme-switcher__thumb { + position: absolute; + top: 4px; + left: 6px; + width: 22px; + height: 22px; + border-radius: 999px; + background: var(--text-primary); + box-shadow: 0 12px 24px -18px rgba(15, 23, 42, 0.9); + transition: transform 0.3s ease, background 0.3s ease, box-shadow 0.3s ease; +} + +.theme-switcher__button[data-state="light"] .theme-switcher__thumb { + transform: translateX(26px); +} + +body[data-theme="light"] .theme-switcher__thumb { + box-shadow: 0 10px 24px -18px rgba(15, 23, 42, 0.4); +} + +.shadcn-card { + background: var(--bg-card); + border: 1px solid var(--border-soft); + border-radius: calc(var(--radius) - 8px); + box-shadow: var(--shadow); + backdrop-filter: blur(10px); + transition: background 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease; +} + +.challenge-hero__tag { + font-family: "Chakra Petch", "Space Grotesk", sans-serif; + letter-spacing: 0.2em; + text-transform: uppercase; + font-size: 0.85rem; + color: var(--accent-strong); + margin: 0 0 12px; +} + +.challenge-hero__title { + font-size: clamp(2.4rem, 5vw, 3.6rem); + margin: 0 0 14px; +} + +.challenge-hero__subtitle { + font-size: 1.1rem; + line-height: 1.6; + color: var(--text-muted); + margin: 0; + max-width: 720px; +} + +.challenge-hero__cta { + display: flex; + flex-wrap: wrap; + gap: 16px; + margin-top: 26px; +} + +.cta-link { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 14px 22px; + border-radius: 999px; + background: var(--accent); + color: var(--text-primary); + text-decoration: none; + font-weight: 600; + box-shadow: var(--cta-shadow); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.cta-link:hover { + transform: translateY(-2px); + box-shadow: var(--cta-shadow-hover); +} + +.cta-link--ghost { + background: transparent; + border: 1px solid var(--ghost-border); + backdrop-filter: blur(6px); + box-shadow: none; +} + +.challenge-main { + max-width: 1080px; + margin: 0 auto; + display: grid; + gap: 28px; +} + +.panel { + background: var(--bg-card); + border: 1px solid var(--border-soft); + border-radius: var(--radius); + padding: 32px; + box-shadow: var(--shadow); + backdrop-filter: blur(10px); + transition: background 0.3s ease, border-color 0.3s ease, color 0.3s ease; +} + +.panel--auth { + position: relative; + overflow: hidden; +} + +.panel--dashboard { + display: grid; + gap: 26px; +} + +.panel__header h2 { + margin: 0 0 6px; +} + +.panel__header p { + margin: 0; + color: var(--text-muted); +} + +.auth-form { + display: grid; + gap: 16px; + margin-top: 24px; +} + +.auth-form__label { + font-weight: 600; + letter-spacing: 0.01em; +} + +.auth-form__input { + width: 100%; + padding: 14px; + border-radius: 16px; + border: 1px solid var(--input-border); + background: var(--input-surface); + color: var(--text-primary); + font-size: 1rem; + transition: border-color 0.2s ease, background 0.2s ease; +} + +.auth-form__input:focus { + outline: none; + border-color: var(--accent); + background: var(--input-surface-focus); +} + +.auth-form__password { + position: relative; +} + +.auth-form__toggle { + position: absolute; + top: 50%; + right: 12px; + transform: translateY(-50%); + background: transparent; + border: none; + font-size: 1.2rem; + cursor: pointer; +} + +.auth-form__submit { + margin-top: 8px; + padding: 16px; + border-radius: 18px; + background: linear-gradient(120deg, var(--accent), #ff8a6a); + border: none; + color: var(--text-primary); + font-size: 1rem; + font-weight: 700; + cursor: pointer; + transition: transform 0.2s ease; +} + +.auth-form__submit:hover { + transform: translateY(-2px); +} + +.auth-form__error { + min-height: 18px; + margin: 0; + color: var(--danger); + font-weight: 600; +} + +.dashboard-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 20px; +} + +.dashboard-header__tag { + margin: 0; + text-transform: uppercase; + font-size: 0.75rem; + letter-spacing: 0.25em; + color: var(--accent-strong); +} + +.dashboard-header__meta { + display: grid; + grid-template-columns: repeat(2, minmax(120px, 1fr)); + gap: 12px; +} + +.meta-card { + padding: 14px 18px; + border-radius: 18px; + background: var(--meta-card-bg); + border: 1px solid var(--border-soft); + text-align: right; + transition: background 0.3s ease, border-color 0.3s ease, color 0.3s ease; +} + +.meta-card--accent { + background: var(--meta-card-accent); +} + +.meta-card__label { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.2em; + color: var(--text-muted); +} + +.meta-card__value { + display: block; + font-size: 1.4rem; + font-weight: 700; +} + +.panel-block { + background: var(--bg-card-strong); + border-radius: 26px; + border: 1px solid var(--border-soft); + padding: 28px; + transition: background 0.3s ease, border-color 0.3s ease; +} + +.panel-block__header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; +} + +.panel-block__header h3 { + margin: 0; +} + +.panel-block__header p { + margin: 4px 0 0; + color: var(--text-muted); +} + +.momentum-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 18px; + margin-top: 24px; +} + +.momentum-card { + background: var(--momentum-card-bg); + border-radius: 20px; + border: 1px solid var(--momentum-card-border); + padding: 20px; + position: relative; + overflow: hidden; + transition: background 0.3s ease, border-color 0.3s ease; +} + +.momentum-card::after { + content: ""; + position: absolute; + inset: 0; + background: radial-gradient(circle at top right, var(--momentum-card-overlay), transparent 55%); + mix-blend-mode: screen; +} + +.momentum-card--highlight { + border-color: var(--accent-ring); +} + +.momentum-card h4 { + margin: 0; + font-size: 0.96rem; + color: var(--text-muted); + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.momentum-card__value { + margin: 12px 0 6px; + font-size: 2rem; + font-weight: 700; +} + +.momentum-card__note { + margin: 0; + color: var(--text-muted); + font-size: 0.9rem; +} + +.panel-block--chart .panel-block__header { + align-items: flex-start; + flex-wrap: wrap; + gap: 20px; +} + +.panel-block--chart .panel-block__header > div:first-child { + flex: 1 1 220px; +} + +.chart-legend { + display: inline-flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; + font-size: 0.85rem; + color: var(--text-muted); +} + +.chart-legend__item { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.chart-legend__swatch { + width: 14px; + height: 14px; + border-radius: 999px; + display: inline-block; +} + +.chart-legend__swatch--primary { + background: linear-gradient(135deg, var(--accent), var(--accent-strong)); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.2); +} + +.chart-legend__swatch--goal { + background: var(--success); + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1); +} + +body[data-theme="light"] .chart-legend__swatch--primary, +body[data-theme="light"] .chart-legend__swatch--goal { + box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.12); +} + +.chart-card { + position: relative; + overflow: hidden; + padding: 26px; + transition: background 0.3s ease, border-color 0.3s ease; +} + +.chart-card::after { + content: ""; + position: absolute; + inset: 0; + background: radial-gradient(circle at 20% 15%, var(--accent-soft), transparent 60%); + pointer-events: none; +} + +.chart-card__header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 24px; +} + +.chart-card__eyebrow { + margin: 0; + text-transform: uppercase; + letter-spacing: 0.28em; + font-size: 0.72rem; + color: var(--text-muted); +} + +.chart-card__title { + margin: 6px 0 0; + font-size: 1.35rem; +} + +.chart-card__stat { + padding: 14px 18px; + border-radius: 18px; + background: var(--bg-card-strong); + border: 1px solid var(--border-soft); + display: flex; + flex-direction: column; + align-items: flex-end; + min-width: 120px; + gap: 4px; + transition: background 0.3s ease, border-color 0.3s ease, color 0.3s ease; +} + +.chart-card__stat-label { + font-size: 0.7rem; + letter-spacing: 0.24em; + text-transform: uppercase; + color: var(--text-muted); + font-weight: 600; +} + +.chart-card__stat-value { + font-size: 1.8rem; + font-weight: 700; + color: var(--accent-strong); + line-height: 1.1; +} + +.chart-card__body { + margin-top: 24px; + height: 260px; +} + +.chart-card__body canvas { + width: 100%; + height: 100%; +} + +.leaderboard { + margin-top: 22px; + overflow-x: auto; +} + +.leaderboard__table { + width: 100%; + border-collapse: collapse; + color: var(--text-primary); +} + +.leaderboard__table th, +.leaderboard__table td { + padding: 14px 16px; + border-bottom: 1px solid var(--border-soft); +} + +.leaderboard__table tbody tr { + transition: background 0.2s ease; +} + +.leaderboard__table tbody tr:nth-child(odd) { + background: var(--table-row-alt); +} + +.leaderboard__table tbody tr:hover { + background: var(--table-row-hover); +} + +.leaderboard__table .is-self { + background: var(--table-row-self); +} + +.leaderboard__pace { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.leaderboard__pace-bar { + display: inline-block; + width: 70px; + height: 8px; + border-radius: 999px; + overflow: hidden; + background: var(--pace-track); +} + +.leaderboard__pace-bar span { + display: block; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, var(--success), #66b3ff); +} + +.stealth-pill { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + border-radius: 999px; + background: var(--stealth-bg); + border: 1px solid var(--stealth-border); + color: var(--warning); + font-weight: 600; + letter-spacing: 0.04em; +} + +.panel-block__actions { + display: flex; + gap: 12px; +} + +.ghost-button { + padding: 10px 18px; + border-radius: 999px; + border: 1px solid var(--ghost-border); + background: transparent; + color: var(--text-primary); + cursor: pointer; + font-size: 0.95rem; + transition: background 0.2s ease; +} + +.ghost-button:hover { + background: var(--ghost-hover); +} + +.ghost-button--wide { + width: 100%; + justify-content: center; + display: inline-flex; + align-items: center; +} + +.ghost-button--upload { + text-align: center; +} + +.weeks { + margin-top: 26px; + display: grid; + gap: 18px; +} + +.week-card { + border-radius: 24px; + border: 1px solid var(--week-card-border); + background: var(--week-card-bg); + overflow: hidden; + transition: background 0.3s ease, border-color 0.3s ease; +} + +.week-card__header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 18px 22px; + cursor: pointer; +} + +.week-card__header h4 { + margin: 0; + font-size: 1.1rem; +} + +.week-card__header span { + color: var(--text-muted); + font-size: 0.9rem; +} + +.week-card__body { + padding: 0 22px 22px; + display: none; +} + +.week-card.is-open .week-card__body { + display: block; +} + +.week-card__days { + width: 100%; + border-collapse: collapse; +} + +.week-card__days th, +.week-card__days td { + text-align: left; + padding: 12px 6px; + border-bottom: 1px solid var(--border-soft); +} + +.week-card__days tbody tr:last-child td { + border-bottom: none; +} + +.week-card__days input { + width: 120px; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid var(--input-border-strong); + background: var(--input-bg-muted); + color: var(--text-primary); +} + +.week-card__days input:focus { + outline: none; + border-color: var(--accent); + background: var(--input-bg-focus); +} + +.week-card__total { + font-weight: 600; + color: var(--accent-strong); +} + +.week-card__day-label { + display: flex; + align-items: center; + gap: 8px; +} + +.week-card__day-label span { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 8px; + background: var(--day-chip-bg); + font-size: 0.8rem; +} + +.week-card__day-label.is-today { + color: var(--success); +} + +.week-card__day-label.is-today span { + background: var(--day-chip-today-bg); +} + +.backup-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 14px; + margin-top: 18px; +} + +.dashboard-footer { + text-align: center; + color: var(--text-muted); + font-size: 0.9rem; +} + +code { + font-family: "Chakra Petch", "Space Grotesk", monospace; + background: var(--code-bg); + padding: 2px 6px; + border-radius: 6px; +} + +@media (max-width: 760px) { + body.challenge-body { + padding: 0 12px 60px; + } + + .challenge-hero { + padding-top: 90px; + } + + .theme-switcher { + position: static; + align-self: flex-end; + margin-bottom: 20px; + order: -1; + } + + .challenge-hero__inner { + padding: 32px 24px; + } + + .panel { + padding: 26px; + } + + .panel-block { + padding: 24px; + } + + .dashboard-header { + flex-direction: column; + align-items: flex-start; + } + + .dashboard-header__meta { + width: 100%; + grid-template-columns: repeat(2, 1fr); + } + + .panel-block__header { + flex-direction: column; + align-items: flex-start; + } + + .panel-block__actions { + width: 100%; + } + + .panel-block__actions .ghost-button { + flex: 1; + text-align: center; + } + + .panel-block--chart .panel-block__header { + flex-direction: column; + align-items: flex-start; + } + + .chart-legend { + width: 100%; + justify-content: flex-start; + } + + .chart-card { + padding: 22px; + } + + .chart-card__header { + flex-direction: column; + align-items: stretch; + } + + .chart-card__stat { + align-self: flex-start; + width: 100%; + flex-direction: row; + justify-content: space-between; + } + + .chart-card__stat-value { + font-size: 1.6rem; + } + + .chart-card__body { + height: 220px; + } +}