HydroFlux 0.0.1
This commit is contained in:
28
js/app.js
Normal file
28
js/app.js
Normal file
@@ -0,0 +1,28 @@
|
||||
console.log('HydroFlux Initialized');
|
||||
|
||||
// Simple Navigation Logic
|
||||
document.querySelectorAll('.nav-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
// Toggle Active State
|
||||
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
|
||||
e.currentTarget.classList.add('active');
|
||||
|
||||
const view = e.currentTarget.dataset.view;
|
||||
|
||||
// Hide all sections
|
||||
document.querySelectorAll('section').forEach(el => el.style.display = 'none');
|
||||
|
||||
// Show target section
|
||||
const target = document.getElementById(`${view}-section`);
|
||||
if (target) target.style.display = 'block';
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize Modules
|
||||
import { WaterTracker } from './modules/water.js?v=2';
|
||||
import { StreakTracker } from './modules/streak.js?v=2';
|
||||
import { FitnessDashboard } from './modules/fitness.js?v=2';
|
||||
|
||||
const waterTracker = new WaterTracker('water-section');
|
||||
const streakTracker = new StreakTracker('streak-section');
|
||||
const fitnessDashboard = new FitnessDashboard('fitness-section');
|
||||
153
js/modules/fitness.js
Normal file
153
js/modules/fitness.js
Normal file
@@ -0,0 +1,153 @@
|
||||
export class FitnessDashboard {
|
||||
constructor(containerId) {
|
||||
this.container = document.getElementById(containerId);
|
||||
// Mock Data
|
||||
this.data = {
|
||||
steps: { current: 8432, goal: 10000 },
|
||||
sleep: { current: 6.5, goal: 8 },
|
||||
history: {
|
||||
steps: [4500, 7200, 10500, 8900, 6000, 11200, 8432],
|
||||
sleep: [5.5, 6.0, 7.5, 8.2, 5.0, 9.0, 6.5]
|
||||
}
|
||||
};
|
||||
|
||||
this.render();
|
||||
// Delay animation to allow DOM paint
|
||||
setTimeout(() => this.animate(), 100);
|
||||
}
|
||||
|
||||
render() {
|
||||
const stepPercent = Math.min((this.data.steps.current / this.data.steps.goal) * 100, 100);
|
||||
const sleepPercent = Math.min((this.data.sleep.current / this.data.sleep.goal) * 100, 100);
|
||||
|
||||
// Ring Config
|
||||
const center = 100;
|
||||
const radiusOuter = 80;
|
||||
const radiusInner = 55;
|
||||
|
||||
const circumOuter = 2 * Math.PI * radiusOuter;
|
||||
const circumInner = 2 * Math.PI * radiusInner;
|
||||
|
||||
this.container.innerHTML = `
|
||||
<div class="fitness-container">
|
||||
<h2 class="section-title">DAILY ACTIVITY</h2>
|
||||
|
||||
<!-- Top: Concentric Rings -->
|
||||
<div class="rings-wrapper">
|
||||
<svg class="concentric-svg" viewBox="0 0 200 200">
|
||||
<!-- Outer Track (Steps) -->
|
||||
<circle class="ring-bg" cx="${center}" cy="${center}" r="${radiusOuter}" stroke-width="18"></circle>
|
||||
<!-- Inner Track (Sleep) -->
|
||||
<circle class="ring-bg" cx="${center}" cy="${center}" r="${radiusInner}" stroke-width="18"></circle>
|
||||
|
||||
<!-- Outer Progress (Steps - Cyan) -->
|
||||
<circle class="ring-progress cyan" cx="${center}" cy="${center}" r="${radiusOuter}"
|
||||
stroke-width="18"
|
||||
stroke-dasharray="${circumOuter}"
|
||||
stroke-dashoffset="${circumOuter}"
|
||||
data-offset="${circumOuter - (stepPercent / 100) * circumOuter}"></circle>
|
||||
|
||||
<!-- Inner Progress (Sleep - Purple) -->
|
||||
<circle class="ring-progress purple" cx="${center}" cy="${center}" r="${radiusInner}"
|
||||
stroke-width="18"
|
||||
stroke-dasharray="${circumInner}"
|
||||
stroke-dashoffset="${circumInner}"
|
||||
data-offset="${circumInner - (sleepPercent / 100) * circumInner}"></circle>
|
||||
|
||||
<!-- Icons/Center -->
|
||||
<image href="https://fonts.gstatic.com/s/i/materialicons/bolt/v5/24px.svg" x="90" y="90" height="20" width="20" style="filter: invert(1); opacity: 0.5;" />
|
||||
</svg>
|
||||
|
||||
<!-- Legend to explain the rings -->
|
||||
<div class="rings-legend">
|
||||
<div class="legend-item">
|
||||
<span class="dot cyan"></span> STEPS
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="dot purple"></span> SLEEP
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Middle: Device -->
|
||||
<div class="device-card">
|
||||
<div class="device-info">
|
||||
<span class="device-name">TicWatch Pro 5</span>
|
||||
<span class="device-status">Disconnected</span>
|
||||
</div>
|
||||
<button id="connect-watch-btn" class="connect-glow-btn">LINK</button>
|
||||
</div>
|
||||
|
||||
<!-- Box 1: Steps (Current + History) -->
|
||||
<div class="stat-card">
|
||||
<div class="stat-header">
|
||||
<div>
|
||||
<span class="stat-label">STEPS</span>
|
||||
<div class="stat-value">${this.data.steps.current}</div>
|
||||
<div class="stat-sub">Goal: ${this.data.steps.goal}</div>
|
||||
</div>
|
||||
<div class="icon-box cyan-box">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-divider"></div>
|
||||
|
||||
<div class="chart-container small">
|
||||
${this.generateBars(this.data.history.steps, 12000, 'cyan')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Box 2: Sleep (Current + History) -->
|
||||
<div class="stat-card">
|
||||
<div class="stat-header">
|
||||
<div>
|
||||
<span class="stat-label">SLEEP</span>
|
||||
<div class="stat-value">${this.data.sleep.current}h</div>
|
||||
<div class="stat-sub">Goal: ${this.data.sleep.goal}h</div>
|
||||
</div>
|
||||
<div class="icon-box purple-box">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-divider"></div>
|
||||
|
||||
<div class="chart-container small">
|
||||
${this.generateBars(this.data.history.sleep, 10, 'purple')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.attachEvents();
|
||||
}
|
||||
|
||||
generateBars(data, max, colorClass) {
|
||||
const days = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
|
||||
return data.map((val, index) => {
|
||||
const height = Math.min((val / max) * 100, 100);
|
||||
return `
|
||||
<div class="chart-column">
|
||||
<div class="chart-bar ${colorClass}" style="height: ${height}%"></div>
|
||||
<span class="chart-day">${days[index]}</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
animate() {
|
||||
this.container.querySelectorAll('.ring-progress').forEach(ring => {
|
||||
ring.style.strokeDashoffset = ring.dataset.offset;
|
||||
});
|
||||
}
|
||||
|
||||
attachEvents() {
|
||||
const btn = this.container.querySelector('#connect-watch-btn');
|
||||
if (btn) {
|
||||
btn.addEventListener('click', () => {
|
||||
alert("Placeholder: This feature would connect to the WearOS API in the native app.");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
102
js/modules/streak.js
Normal file
102
js/modules/streak.js
Normal file
@@ -0,0 +1,102 @@
|
||||
export class StreakTracker {
|
||||
constructor(containerId) {
|
||||
this.container = document.getElementById(containerId);
|
||||
this.STORAGE_KEY = 'hydroflux_streak';
|
||||
this.quotes = [
|
||||
"The only easy day was yesterday.",
|
||||
"Discipline is doing what needs to be done, even if you don't want to.",
|
||||
"Your future self is watching you right now through memories.",
|
||||
"Pain is temporary. Quitting lasts forever.",
|
||||
"Suffering is the currency of success.",
|
||||
"Don't stop when you're tired. Stop when you're done.",
|
||||
"You are stronger than your urges.",
|
||||
"Focus on the goal, not the obstacle."
|
||||
];
|
||||
|
||||
this.loadState();
|
||||
this.render();
|
||||
this.startTimer();
|
||||
}
|
||||
|
||||
loadState() {
|
||||
const saved = localStorage.getItem(this.STORAGE_KEY);
|
||||
if (saved) {
|
||||
this.startDate = new Date(parseInt(saved));
|
||||
} else {
|
||||
this.startDate = new Date();
|
||||
this.saveState();
|
||||
}
|
||||
}
|
||||
|
||||
saveState() {
|
||||
localStorage.setItem(this.STORAGE_KEY, this.startDate.getTime().toString());
|
||||
}
|
||||
|
||||
resetStreak() {
|
||||
if (confirm("Are you sure you want to reset your streak?")) {
|
||||
this.startDate = new Date();
|
||||
this.saveState();
|
||||
this.updateUI();
|
||||
|
||||
// Haptic Bad Feedback
|
||||
if (navigator.vibrate) navigator.vibrate([100, 50, 100]);
|
||||
}
|
||||
}
|
||||
|
||||
getDuration() {
|
||||
const now = new Date();
|
||||
const diff = now - this.startDate;
|
||||
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
return { days, hours, minutes };
|
||||
}
|
||||
|
||||
updateUI() {
|
||||
const { days, hours, minutes } = this.getDuration();
|
||||
|
||||
const daysEl = this.container.querySelector('.streak-days');
|
||||
const detailEl = this.container.querySelector('.streak-detail');
|
||||
|
||||
if (daysEl) daysEl.textContent = days;
|
||||
if (detailEl) detailEl.textContent = `${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
startTimer() {
|
||||
setInterval(() => this.updateUI(), 60000); // Update every minute
|
||||
}
|
||||
|
||||
getRandomQuote() {
|
||||
return this.quotes[Math.floor(Math.random() * this.quotes.length)];
|
||||
}
|
||||
|
||||
render() {
|
||||
this.container.innerHTML = `
|
||||
<div class="streak-container">
|
||||
<h2 class="section-title">ABSTINENCE STREAK</h2>
|
||||
|
||||
<div class="streak-counter">
|
||||
<div class="streak-days glow-text">0</div>
|
||||
<div class="streak-label">DAYS</div>
|
||||
<div class="streak-detail">${0}h ${0}m</div>
|
||||
</div>
|
||||
|
||||
<div class="quote-card">
|
||||
"${this.getRandomQuote()}"
|
||||
</div>
|
||||
|
||||
<button id="reset-streak-btn" class="danger-btn">
|
||||
RESET STREAK
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.updateUI();
|
||||
|
||||
this.container.querySelector('#reset-streak-btn').addEventListener('click', () => {
|
||||
this.resetStreak();
|
||||
});
|
||||
}
|
||||
}
|
||||
184
js/modules/water.js
Normal file
184
js/modules/water.js
Normal file
@@ -0,0 +1,184 @@
|
||||
|
||||
export class WaterTracker {
|
||||
constructor(containerId) {
|
||||
this.container = document.getElementById(containerId);
|
||||
this.state = {
|
||||
current: 0,
|
||||
goal: 3000, // mL
|
||||
bottleSize: 500, // mL
|
||||
};
|
||||
this.STORAGE_KEY = 'hydroflux_data';
|
||||
this.loadState();
|
||||
this.render();
|
||||
this.attachEvents();
|
||||
this.updateUI();
|
||||
}
|
||||
|
||||
loadState() {
|
||||
const saved = localStorage.getItem(this.STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
this.state = { ...this.state, ...parsed };
|
||||
}
|
||||
}
|
||||
|
||||
saveState() {
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(this.state));
|
||||
this.updateUI();
|
||||
}
|
||||
|
||||
addWater() {
|
||||
this.state.current += this.state.bottleSize;
|
||||
this.saveState();
|
||||
if (navigator.vibrate) navigator.vibrate(50);
|
||||
}
|
||||
|
||||
removeWater() {
|
||||
this.state.current = Math.max(0, this.state.current - this.state.bottleSize);
|
||||
this.saveState();
|
||||
if (navigator.vibrate) navigator.vibrate(50);
|
||||
}
|
||||
|
||||
setBottleSize(size) {
|
||||
if (!size || size <= 0) return;
|
||||
this.state.bottleSize = size;
|
||||
this.saveState();
|
||||
}
|
||||
|
||||
getPercentage() {
|
||||
return Math.min(100, Math.max(0, (this.state.current / this.state.goal) * 100));
|
||||
}
|
||||
|
||||
updateUI() {
|
||||
// Update Text
|
||||
const currentEl = this.container.querySelector('.water-count');
|
||||
const percentageEl = this.container.querySelector('.water-percentage');
|
||||
|
||||
if (currentEl) currentEl.textContent = `${this.state.current} / ${this.state.goal} mL`;
|
||||
if (percentageEl) percentageEl.textContent = `${Math.round(this.getPercentage())}%`;
|
||||
|
||||
// Update Wave Animation Height
|
||||
const wave = this.container.querySelector('.wave');
|
||||
if (wave) {
|
||||
wave.style.top = `${100 - this.getPercentage()}%`;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
this.container.innerHTML = `
|
||||
<div class="water-tracker-container">
|
||||
<!-- Circular Progress -->
|
||||
<div class="circle-container">
|
||||
<div class="water-circle">
|
||||
<div class="wave"></div>
|
||||
<div class="circle-content">
|
||||
<span class="water-percentage">0%</span>
|
||||
<span class="water-label">HYDRATION</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Display -->
|
||||
<div class="stats-row">
|
||||
<span class="water-count">0 / 3000 mL</span>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="controls-area">
|
||||
<div class="bottle-selector">
|
||||
<label>Bottle (mL):</label>
|
||||
<input type="number" id="bottle-size-input" value="${this.state.bottleSize}" min="1" max="5000">
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<button id="remove-water-btn" class="icon-btn secondary" aria-label="Remove Drink">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M5 12h14"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button id="add-water-btn" class="glow-btn">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 5v14M5 12h14"/>
|
||||
</svg>
|
||||
DRINK
|
||||
</button>
|
||||
|
||||
<!-- Notification Toggle -->
|
||||
<button id="notify-btn" class="icon-btn" title="Enable Reminders">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path>
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.checkNotificationStatus();
|
||||
}
|
||||
|
||||
attachEvents() {
|
||||
this.container.querySelector('#add-water-btn').addEventListener('click', () => {
|
||||
this.addWater();
|
||||
});
|
||||
|
||||
this.container.querySelector('#remove-water-btn').addEventListener('click', () => {
|
||||
this.removeWater();
|
||||
});
|
||||
|
||||
this.container.querySelector('#notify-btn').addEventListener('click', (e) => {
|
||||
this.toggleNotifications(e.currentTarget);
|
||||
});
|
||||
|
||||
const input = this.container.querySelector('#bottle-size-input');
|
||||
input.addEventListener('change', (e) => {
|
||||
this.setBottleSize(parseInt(e.target.value));
|
||||
});
|
||||
}
|
||||
|
||||
// --- Notification Logic ---
|
||||
|
||||
toggleNotifications(btn) {
|
||||
if (!("Notification" in window)) {
|
||||
alert("This browser does not support desktop notifications");
|
||||
return;
|
||||
}
|
||||
|
||||
if (Notification.permission === "granted") {
|
||||
alert("Reminders are active! We'll check every hour.");
|
||||
} else if (Notification.permission !== "denied") {
|
||||
Notification.requestPermission().then(permission => {
|
||||
if (permission === "granted") {
|
||||
this.startReminderLoop();
|
||||
btn.style.color = "var(--primary-cyan)";
|
||||
new Notification("HydroFlux", { body: "Smart Hydration Reminders Enabled!" });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
checkNotificationStatus() {
|
||||
if (Notification.permission === "granted") {
|
||||
const btn = this.container.querySelector('#notify-btn');
|
||||
if (btn) btn.style.color = "var(--primary-cyan)";
|
||||
this.startReminderLoop();
|
||||
}
|
||||
}
|
||||
|
||||
startReminderLoop() {
|
||||
// Clear existing to avoid duplicates
|
||||
if (this.reminderInterval) clearInterval(this.reminderInterval);
|
||||
|
||||
// Check every minute if it's been > 1 hour since last drink
|
||||
this.reminderInterval = setInterval(() => {
|
||||
// Pseudo-logic check since we don't store timestamp in this simple version yet
|
||||
// In a real app, you'd check this.state.lastDrinkTime
|
||||
new Notification("HydroFlux Needs You", {
|
||||
body: "Remember to drink water!",
|
||||
icon: "/icon.png"
|
||||
});
|
||||
}, 3600000); // 1 Hour
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user