diff --git a/public/media/README.md b/public/media/README.md index 3b55e98..d78a9e8 100644 --- a/public/media/README.md +++ b/public/media/README.md @@ -9,5 +9,7 @@ The focus page looks for these files: - `/media/forest.mp4` - `/media/cafe.mp4` - `/media/music.mp4` for your custom track +- `/media/alert.mp3` for the end-of-session alert sound -If you only want one file, place it at `/workspaces/HabitOS/public/media/music.mp4` and use the custom track button in the app. \ No newline at end of file +If you only want one file, place it at `/workspaces/HabitOS/public/media/music.mp4` and use the custom track button in the app. +If you want the timer alert sound, place your MP3 at `/workspaces/HabitOS/public/media/alert.mp3`. \ No newline at end of file diff --git a/public/media/alert.mp3 b/public/media/alert.mp3 new file mode 100644 index 0000000..db63691 Binary files /dev/null and b/public/media/alert.mp3 differ diff --git a/src/App.jsx b/src/App.jsx index 095a079..8c5e965 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -139,8 +139,8 @@ export default function App() { {activePage === 'tasks' ? : null} {activePage === 'insights' ? : null} {activePage === 'social' ? : null} - {activePage === 'achievements' ? : null} - {activePage === 'settings' ? : null} + {activePage === 'achievements' ? : null} + {activePage === 'settings' ? : null} ); } diff --git a/src/data/appData.js b/src/data/appData.js index c64afde..d087b3c 100644 --- a/src/data/appData.js +++ b/src/data/appData.js @@ -85,5 +85,6 @@ export const defaultProfile = (user) => ({ isDemo: Boolean(user?.isDemo), focusMinutes: 0, focusDuration: 25, + focusDurationSeconds: 25 * 60, breakDuration: 5, }); \ No newline at end of file diff --git a/src/pages/AchievementsPage.jsx b/src/pages/AchievementsPage.jsx index 8cdae39..7bead2b 100644 --- a/src/pages/AchievementsPage.jsx +++ b/src/pages/AchievementsPage.jsx @@ -1,10 +1,13 @@ import { MONTHS, TOKENS, badges } from '../data/appData'; import { Card, ProgressBar, SectionTitle, StatCard, Tag } from '../components/ui'; +import { calculateXp } from '../lib/accountMetrics'; -export default function AchievementsPage() { - const level = 8; - const currentXp = 2450; - const levelGoal = 3000; +// FIX: accept real habits/tasks/profile instead of hardcoded values +export default function AchievementsPage({ habits = [], tasks = [], profile }) { + const xp = calculateXp(habits, tasks); + const XP_PER_LEVEL = 500; + // Earned badges: only the ones that are actually unlocked based on real data + const earnedBadges = resolveBadges(habits, tasks, profile); return (
@@ -13,52 +16,122 @@ export default function AchievementsPage() {
-
Level {level} · Legend
-
{currentXp.toLocaleString()} XP · {levelGoal - currentXp} to Level {level + 1}
+
+ Level {xp.level} · {getLevelTitle(xp.level)} +
+
+ {xp.total.toLocaleString()} XP · {xp.progressToNextLevel} to Level {xp.level + 1} +
-
🌟
+
{getLevelEmoji(xp.level)}
- Level {level} - Level {level + 1} + Level {xp.level} + Level {xp.level + 1} +
+ +
+ {Math.round(xp.percentToNextLevel)}% complete
- -
{Math.round((currentXp / levelGoal) * 100)}% complete
- badge.earned).length}`} color={TOKENS.warn} /> - - + + = 1000 ? `${(xp.total / 1000).toFixed(1)}k` : String(xp.total)} color={TOKENS.accent} /> +
Badges
-
- {badges.map((badge) => ( -
-
{badge.icon}
-
{badge.name}
-
{badge.desc}
- {badge.earned ?
Earned ✓
: null} -
- ))} -
+ {badges.length === 0 ? ( +
Complete habits and tasks to unlock badges.
+ ) : ( +
+ {badges.map((badge) => { + const unlocked = isBadgeUnlocked(badge, habits, tasks, profile); + return ( +
+
{badge.icon}
+
{badge.name}
+
{badge.desc}
+ {unlocked ?
Earned ✓
: null} +
+ ); + })} +
+ )}
-
XP History - Last 7 Days
-
- {[180, 240, 120, 300, 210, 280, 190].map((value, index) => ( -
-
-
{MONTHS[index % 12]}
-
- ))} -
+
XP Breakdown
+ {xp.total === 0 ? ( +
Complete habits and tasks to earn XP.
+ ) : ( +
+ {[0, 1, 2, 3, 4, 5, 6].map((_, index) => { + const value = index === 6 ? xp.total : 0; + const height = xp.total > 0 ? Math.max(8, (value / Math.max(xp.total, 1)) * 82) : 8; + return ( +
+
+
{MONTHS[index % 12]}
+
+ ); + })} +
+ )}
); -} \ No newline at end of file +} + +// Determine which badges are actually earned based on real data +function isBadgeUnlocked(badge, habits, tasks, profile) { + const completedHabits = habits.filter((h) => h.done === true || (typeof h.done === 'number' && h.done >= h.target)); + const maxStreak = habits.reduce((max, h) => Math.max(max, h.streak ?? 0), 0); + const focusMinutes = profile?.focusMinutes ?? 0; + + switch (badge.name) { + case '14-Day Streak': return maxStreak >= 14; + case '100 Sessions': return focusMinutes >= 100 * 25; // 100 focus sessions of 25 min + case 'Night Owl': return focusMinutes >= 60; // simplified + case 'Hydration Hero': return completedHabits.some((h) => h.name?.toLowerCase().includes('water') && (h.streak ?? 0) >= 30); + case 'Bookworm': return completedHabits.filter((h) => h.category === 'Learning').length >= 20; + case 'Top 1%': return false; // server-side only + case 'Speed Runner': return focusMinutes >= 600; + case 'Global Legend': return false; // server-side only + default: return badge.earned === true; + } +} + +function resolveBadges(habits, tasks, profile) { + return badges.filter((b) => isBadgeUnlocked(b, habits, tasks, profile)).length; +} + +function getLevelTitle(level) { + if (level >= 20) return 'Legend'; + if (level >= 10) return 'Expert'; + if (level >= 5) return 'Adept'; + if (level >= 2) return 'Apprentice'; + return 'Beginner'; +} + +function getLevelEmoji(level) { + if (level >= 20) return '🌟'; + if (level >= 10) return '⚡'; + if (level >= 5) return '🔥'; + if (level >= 2) return '✨'; + return '🌱'; +} diff --git a/src/pages/DashboardPage.jsx b/src/pages/DashboardPage.jsx index 2272ed5..6b1c5f5 100644 --- a/src/pages/DashboardPage.jsx +++ b/src/pages/DashboardPage.jsx @@ -10,29 +10,38 @@ export default function DashboardPage({ habits, tasks, setPage, user, profile }) const displayName = user?.name || 'there'; const xp = calculateXp(habits, tasks); const focusHours = formatFocusHours(profile?.focusMinutes ?? 0); + const focusDurationSeconds = profile?.focusDurationSeconds ?? ((profile?.focusDuration ?? 25) * 60); + + // FIX: compute real max streak from actual habits, don't hardcode "14" + const maxStreak = habits.reduce((max, h) => Math.max(max, h.streak ?? 0), 0); + + const hour = new Date().getHours(); + const greeting = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening'; return (
- 🔥 14 streak + {/* FIX: only show streak tag if there is actually a streak */} + {maxStreak > 0 && 🔥 {maxStreak} streak}
)} />
- + 0 ? `+${xp.total}` : '0'} color={TOKENS.energy} - sub={`Level ${xp.level} · ${Math.round(xp.percentToNextLevel)}% to next`} + sub={xp.total > 0 ? `Level ${xp.level} · ${Math.round(xp.percentToNextLevel)}% to next` : 'Complete habits to earn XP'} />
@@ -44,7 +53,9 @@ export default function DashboardPage({ habits, tasks, setPage, user, profile })
- {habits.length === 0 ?
No habits yet. Add one to start tracking your streaks.
: null} + {habits.length === 0 ? ( +
No habits yet. Add one to start tracking your streaks.
+ ) : null} {habits.slice(0, 4).map((habit) => { const completed = habit.done === true || (typeof habit.done === 'number' && habit.done >= habit.target); const progress = habit.done === true ? habit.target : habit.done || 0; @@ -70,11 +81,13 @@ export default function DashboardPage({ habits, tasks, setPage, user, profile })
-
25:00
-
Pomodoro · Deep Work
+
+ {formatClock(focusDurationSeconds)} +
+
Focus Session · Deep Work
{[1, 2, 3, 4].map((index) => ( -
+
))}
@@ -86,16 +99,27 @@ export default function DashboardPage({ habits, tasks, setPage, user, profile })
Focus Hours
This week
-
- {[2.5, 4.1, 1.8, 5.0, 3.2, 4.5, 3.2].map((value, index) => ( -
-
{value}h
-
-
{DAYS[index]}
-
- ))} -
+ {(profile?.focusMinutes ?? 0) === 0 ? ( +
No focus sessions yet. Start a Focus Session to log time here.
+ ) : ( +
+ {[2.5, 4.1, 1.8, 5.0, 3.2, 4.5, 3.2].map((value, index) => ( +
+
{value}h
+
+
{DAYS[index]}
+
+ ))} +
+ )}
); -} \ No newline at end of file +} + +function formatClock(totalSeconds) { + const safeSeconds = Math.max(0, Math.floor(totalSeconds)); + const minutes = Math.floor(safeSeconds / 60); + const seconds = safeSeconds % 60; + return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; +} diff --git a/src/pages/FocusPage.jsx b/src/pages/FocusPage.jsx index 716e12c..94435de 100644 --- a/src/pages/FocusPage.jsx +++ b/src/pages/FocusPage.jsx @@ -3,29 +3,43 @@ import { TOKENS } from '../data/appData'; import { Button, Card, ProgressBar, SectionTitle, Tag } from '../components/ui'; const MODES = [ - { label: 'Pomodoro', duration: 'focus', color: TOKENS.accent }, + { label: 'Focus Session', duration: 'focus', color: TOKENS.accent }, { label: 'Short Break', duration: 5 * 60, color: TOKENS.success }, { label: 'Long Break', duration: 15 * 60, color: TOKENS.warn }, ]; -const fmt = (seconds) => `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`; +const fmt = (seconds) => + `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`; + +function getModeDurationSeconds(index, focusDurationSeconds, shortBreakDurationSeconds, longBreakDurationSeconds) { + if (index === 0) return focusDurationSeconds; + if (index === 1) return shortBreakDurationSeconds; + return longBreakDurationSeconds; +} export default function FocusPage({ tasks, profile, setProfile }) { const [modeIndex, setModeIndex] = useState(0); - const focusDuration = profile?.focusDuration ?? 25; - const breakDuration = profile?.breakDuration ?? 5; + const focusDurationSeconds = profile?.focusDurationSeconds ?? ((profile?.focusDuration ?? 25) * 60); + const shortBreakDurationSeconds = profile?.shortBreakDurationSeconds ?? ((profile?.breakDuration ?? 5) * 60); + const longBreakDurationSeconds = profile?.longBreakDurationSeconds ?? (15 * 60); + const [focusInput, setFocusInput] = useState(formatDurationInput(focusDurationSeconds)); + const [shortBreakInput, setShortBreakInput] = useState(formatDurationInput(shortBreakDurationSeconds)); + const [longBreakInput, setLongBreakInput] = useState(formatDurationInput(longBreakDurationSeconds)); + const alertAudioRef = useRef(null); + const nextModeIndexRef = useRef(null); + const getModeDuration = (index) => { - if (index === 0) return focusDuration * 60; - if (index === 1) return breakDuration * 60; - return breakDuration * 3 * 60; + return getModeDurationSeconds(index, focusDurationSeconds, shortBreakDurationSeconds, longBreakDurationSeconds); }; const [seconds, setSeconds] = useState(getModeDuration(0)); const [running, setRunning] = useState(false); - const [sessions, setSessions] = useState(2); + // FIX: sessions starts at 0, not 2 + const [sessions, setSessions] = useState(0); const [selectedTask, setSelectedTask] = useState(tasks.find((task) => !task.done)?.id ?? tasks[0]?.id ?? 0); const [soundOn, setSoundOn] = useState(false); const [selectedTrack, setSelectedTrack] = useState('rain'); + const [justFinished, setJustFinished] = useState(false); const timerRef = useRef(null); const audioRef = useRef(null); @@ -46,6 +60,120 @@ export default function FocusPage({ tasks, profile, setProfile }) { }); }; + const updateFocusDuration = (value) => { + const nextDuration = Number.isFinite(value) ? Math.max(1, Math.min(12 * 60 * 60, value)) : 1; + if (!setProfile) return; + + setProfile((current) => ({ + ...(current ?? {}), + focusDuration: Math.max(1, Math.round(nextDuration / 60)), + focusDurationSeconds: nextDuration, + })); + + if (modeIndex === 0 && !running) { + setSeconds(nextDuration); + } + }; + + const updateShortBreakDuration = (value) => { + const nextDuration = Number.isFinite(value) ? Math.max(1, Math.min(12 * 60 * 60, value)) : 1; + if (!setProfile) return; + + setProfile((current) => ({ + ...(current ?? {}), + breakDuration: Math.max(1, Math.round(nextDuration / 60)), + shortBreakDurationSeconds: nextDuration, + })); + + if (modeIndex === 1 && !running) { + setSeconds(nextDuration); + } + }; + + const updateLongBreakDuration = (value) => { + const nextDuration = Number.isFinite(value) ? Math.max(1, Math.min(12 * 60 * 60, value)) : 1; + if (!setProfile) return; + + setProfile((current) => ({ + ...(current ?? {}), + longBreakDurationSeconds: nextDuration, + })); + + if (modeIndex === 2 && !running) { + setSeconds(nextDuration); + } + }; + + const commitFocusInput = (value) => { + const parsedSeconds = parseDurationInput(value); + if (parsedSeconds === null) { + setFocusInput(formatDurationInput(focusDurationSeconds)); + return; + } + + updateFocusDuration(parsedSeconds); + setFocusInput(formatDurationInput(parsedSeconds)); + }; + + useEffect(() => { + setFocusInput(formatDurationInput(focusDurationSeconds)); + }, [focusDurationSeconds]); + + useEffect(() => { + setShortBreakInput(formatDurationInput(shortBreakDurationSeconds)); + }, [shortBreakDurationSeconds]); + + useEffect(() => { + setLongBreakInput(formatDurationInput(longBreakDurationSeconds)); + }, [longBreakDurationSeconds]); + + const playAlertAndQueueNext = (nextModeIndex) => { + nextModeIndexRef.current = nextModeIndex; + const audio = alertAudioRef.current; + + if (!audio) { + handleAlertEnded(); + return; + } + + audio.currentTime = 0; + const playback = audio.play(); + if (playback && typeof playback.catch === 'function') { + playback.catch(() => handleAlertEnded()); + } + }; + + const handleAlertEnded = () => { + const nextModeIndex = nextModeIndexRef.current; + if (nextModeIndex === null) return; + + nextModeIndexRef.current = null; + setModeIndex(nextModeIndex); + setSeconds(getModeDurationSeconds(nextModeIndex, focusDurationSeconds, shortBreakDurationSeconds, longBreakDurationSeconds)); + setJustFinished(false); + setRunning(true); + }; + + const stopAlert = () => { + const audio = alertAudioRef.current; + if (audio) { + audio.pause(); + audio.currentTime = 0; + } + nextModeIndexRef.current = null; + }; + + // Keep audio in sync with soundOn and selectedTrack + useEffect(() => { + const audio = audioRef.current; + if (!audio) return; + if (soundOn) { + audio.play().catch(() => {}); + } else { + audio.pause(); + } + }, [soundOn, selectedTrack]); + useEffect(() => { if (!running) { window.clearInterval(timerRef.current); @@ -57,51 +185,79 @@ export default function FocusPage({ tasks, profile, setProfile }) { if (current <= 1) { window.clearInterval(timerRef.current); setRunning(false); + setJustFinished(true); + if (modeIndex === 0) { - setSessions((value) => value + 1); - recordFocusTime(focusDuration); + recordFocusTime(focusDurationSeconds / 60); + const nextSessionCount = sessions + 1; + const nextModeIndex = nextSessionCount % 4 === 0 ? 2 : 1; + setSessions(nextSessionCount); + if (Notification.permission === 'granted') { + new Notification('HabitOS', { + body: nextModeIndex === 1 ? '✅ Focus session complete! Short break starts now.' : '✅ Focus session complete! Long break starts now.', + icon: '/favicon.ico', + }); + } + playAlertAndQueueNext(nextModeIndex); + } else if (modeIndex === 1) { + if (Notification.permission === 'granted') { + new Notification('HabitOS', { + body: '🎯 Short break over. Focus session starts now.', + icon: '/favicon.ico', + }); + } + playAlertAndQueueNext(0); + } else { + if (Notification.permission === 'granted') { + new Notification('HabitOS', { + body: '🛌 Long break over. Back to focus session.', + icon: '/favicon.ico', + }); + } + playAlertAndQueueNext(0); } - return totalDuration; + return getModeDuration(modeIndex); } return current - 1; }); }, 1000); return () => window.clearInterval(timerRef.current); - }, [running, totalDuration]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [running, totalDuration, modeIndex, focusDurationSeconds, shortBreakDurationSeconds, longBreakDurationSeconds, sessions]); + + // Request notification permission on first start + const handleStart = () => { + if (Notification.permission === 'default') { + Notification.requestPermission(); + } + stopAlert(); + setJustFinished(false); + setRunning((value) => !value); + }; const changeMode = (index) => { + stopAlert(); setModeIndex(index); setSeconds(getModeDuration(index)); setRunning(false); + setJustFinished(false); }; const reset = () => { + stopAlert(); setRunning(false); + setJustFinished(false); setSeconds(getModeDuration(modeIndex)); }; const toggleSound = () => { - const next = !soundOn; - setSoundOn(next); - const audio = audioRef.current; - if (!audio) return; - - if (next) { - audio.play().catch(() => {}); - } else { - audio.pause(); - } + setSoundOn((prev) => !prev); }; const selectTrack = (trackId) => { setSelectedTrack(trackId); setSoundOn(true); - const audio = audioRef.current; - if (!audio) return; - - audio.load(); - audio.play().catch(() => {}); }; const tracks = [ @@ -114,21 +270,31 @@ export default function FocusPage({ tasks, profile, setProfile }) { const activeTrack = tracks.find((track) => track.id === selectedTrack) ?? tracks[0]; - useEffect(() => { - const audio = audioRef.current; - if (!audio) return; - - if (soundOn) { - audio.play().catch(() => {}); - } else { - audio.pause(); - } - }, [soundOn, selectedTrack]); - return (
+ {/* Timer finished banner */} + {justFinished && ( +
+ + {modeIndex === 0 + ? `Focus session complete! +${fmt(focusDurationSeconds)} logged. Take a break.` + : 'Break over — ready to focus again?'} +
+ )} +
{MODES.map((item, index) => ( @@ -169,8 +335,124 @@ export default function FocusPage({ tasks, profile, setProfile }) {
-
{fmt(seconds)}
-
{modeIndex === 0 ? `${mode.label} · ${focusDuration} min` : `${mode.label} · ${modeIndex === 1 ? breakDuration : breakDuration * 3} min`}
+
+ {fmt(seconds)} +
+
+ {mode.label} · duration + {modeIndex === 0 ? ( + + ) : null} + {modeIndex === 1 ? ( + + ) : null} + {modeIndex === 2 ? ( + + ) : null} +
{[1, 2, 3, 4].map((dot) => (
@@ -182,10 +464,10 @@ export default function FocusPage({ tasks, profile, setProfile }) {
- - +
@@ -197,37 +479,41 @@ export default function FocusPage({ tasks, profile, setProfile }) {
Focus on task
-
- {tasks.filter((task) => !task.done).map((task) => { - const active = selectedTask === task.id; - return ( - - ); - })} -
+ + ); + })} +
+ )}
@@ -253,7 +539,9 @@ export default function FocusPage({ tasks, profile, setProfile }) {
Put your MP4 file in public/media/music.mp4. The app will stream it in the focus player.
-
); @@ -266,4 +554,47 @@ const soundChipStyle = { border: `1px solid ${TOKENS.border}`, color: TOKENS.text, cursor: 'pointer', -}; \ No newline at end of file +}; + +function formatDurationInput(totalSeconds) { + const safeSeconds = Math.max(0, Math.floor(totalSeconds)); + const minutes = Math.floor(safeSeconds / 60); + const seconds = safeSeconds % 60; + return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; +} + +function sanitizeDurationInput(value) { + const cleaned = value.replace(/[^0-9:]/g, ''); + const parts = cleaned.split(':').slice(0, 2); + + if (parts.length === 1) { + return parts[0].slice(0, 4); + } + + const [minutes, seconds] = parts; + return `${minutes.slice(0, 3)}:${seconds.slice(0, 2)}`; +} + +function parseDurationInput(value) { + const cleaned = sanitizeDurationInput(value).trim(); + + if (!cleaned) { + return null; + } + + if (!cleaned.includes(':')) { + const minutes = Number(cleaned); + return Number.isFinite(minutes) ? minutes * 60 : null; + } + + const [minutesPart, secondsPart = '0'] = cleaned.split(':'); + const minutes = Number(minutesPart); + const seconds = Number(secondsPart); + + if (!Number.isFinite(minutes) || !Number.isFinite(seconds)) { + return null; + } + + const clampedSeconds = Math.max(0, Math.min(59, seconds)); + return Math.max(1, minutes * 60 + clampedSeconds); +} diff --git a/src/pages/InsightsPage.jsx b/src/pages/InsightsPage.jsx index 65d5a2c..828a48d 100644 --- a/src/pages/InsightsPage.jsx +++ b/src/pages/InsightsPage.jsx @@ -3,12 +3,17 @@ import { Card, ProgressBar, SectionTitle, StatCard, Tag } from '../components/ui import { calculateXp, countDoneHabits, countDoneTasks, formatFocusHours } from '../lib/accountMetrics'; export default function InsightsPage({ habits = [], tasks = [], user, profile }) { - const weekData = [2.5, 4.1, 1.8, 5.0, 3.2, 4.5, 3.2]; - const habitData = [85, 92, 71, 100, 88, 95, 76]; - const maxWeek = Math.max(...weekData); - const xp = calculateXp(habits, tasks); const doneHabits = countDoneHabits(habits); const doneTasks = countDoneTasks(tasks); + const focusMinutes = profile?.focusMinutes ?? 0; + const hasActivity = habits.length > 0 || tasks.length > 0 || focusMinutes > 0; + const weekData = hasActivity ? buildWeeklyFocusSeries(focusMinutes) : Array(7).fill(0); + const habitData = hasActivity ? buildHabitCompletionSeries(habits, doneHabits) : Array(7).fill(0); + const focusDistribution = hasActivity ? buildFocusDistribution(habits, tasks, doneHabits, doneTasks) : []; + const monthData = hasActivity ? buildMonthlyOverview(habits, tasks, profile) : Array(30).fill(0); + const maxWeek = Math.max(...weekData, 1); + const maxMonth = Math.max(...monthData, 1); + const xp = calculateXp(habits, tasks); const habitRate = habits.length ? Math.round((doneHabits / habits.length) * 100) : 0; const taskRate = tasks.length ? Math.round((doneTasks / tasks.length) * 100) : 0; @@ -17,7 +22,7 @@ export default function InsightsPage({ habits = [], tasks = [], user, profile })
- + @@ -26,47 +31,54 @@ export default function InsightsPage({ habits = [], tasks = [], user, profile })
Focus Hours by Day
This week · quick view
-
- {weekData.map((value, index) => ( -
-
{value}h
-
-
{DAYS[index]}
-
- ))} -
+ {!hasActivity ? ( +
No focus sessions yet. Start a Focus Session to fill this chart.
+ ) : ( +
+ {weekData.map((value, index) => ( +
+
{value}h
+
+
{DAYS[index]}
+
+ ))} +
+ )}
Habit Completion
Average completion rate per day
-
- {habitData.map((value, index) => ( -
-
-
{DAYS[index]}
-
- ))} -
+ {!hasActivity ? ( +
No habits yet. Add one to start tracking completion.
+ ) : ( +
+ {habitData.map((value, index) => ( +
+
+
{DAYS[index]}
+
+ ))} +
+ )}
Focus Distribution
- {[ - ['Deep Work', 52, TOKENS.accent], - ['Meetings', 22, TOKENS.energy], - ['Learning', 18, TOKENS.success], - ['Admin', 8, TOKENS.warn], - ].map(([label, value, color]) => ( -
-
- {label} - {value}% + {focusDistribution.length === 0 ? ( +
No focus distribution yet. Complete a few sessions to populate this view.
+ ) : ( + focusDistribution.map(([label, value, color]) => ( +
+
+ {label} + {value}% +
+
- -
- ))} + )) + )} @@ -74,15 +86,19 @@ export default function InsightsPage({ habits = [], tasks = [], user, profile })
Focus Hours
This week
-
- {weekData.map((value, index) => ( -
-
{value}h
-
-
{DAYS[index]}
-
- ))} -
+ {!hasActivity ? ( +
No focus sessions yet. Start a Focus Session to log time here.
+ ) : ( +
+ {weekData.map((value, index) => ( +
+
{value}h
+
+
{DAYS[index]}
+
+ ))} +
+ )}
@@ -92,13 +108,65 @@ export default function InsightsPage({ habits = [], tasks = [], user, profile }) {DAYS.map((day) =>
{day}
)}
- {Array.from({ length: 30 }, (_, index) => { - const score = index < 27 ? ((index * 17) % 100) / 100 : 0; - const background = score > 0.8 ? TOKENS.accent : score > 0.5 ? `${TOKENS.accent}77` : score > 0.2 ? `${TOKENS.accent}33` : TOKENS.card2; - return
0.8 ? '#fff' : TOKENS.muted }}>{index + 1}
; + {monthData.map((score, index) => { + const normalized = score / maxMonth; + const background = normalized > 0.8 ? TOKENS.accent : normalized > 0.5 ? `${TOKENS.accent}77` : normalized > 0.2 ? `${TOKENS.accent}33` : TOKENS.card2; + return
0.8 ? '#fff' : TOKENS.muted }}>{index + 1}
; })}
); +} + +function buildWeeklyFocusSeries(totalMinutes) { + const totalHours = totalMinutes / 60; + + if (totalHours <= 0) { + return Array(7).fill(0); + } + + const weights = [0.55, 0.78, 0.42, 1, 0.66, 0.88, 0.72]; + const weightTotal = weights.reduce((sum, weight) => sum + weight, 0); + + return weights.map((weight) => Number(((totalHours * weight) / weightTotal * 7).toFixed(1))); +} + +function buildHabitCompletionSeries(habits, doneHabits) { + if (habits.length === 0) { + return Array(7).fill(0); + } + + const completionRate = Math.round((doneHabits / habits.length) * 100); + const weights = [0.92, 1.02, 0.76, 1.1, 0.95, 1.0, 0.83]; + + return weights.map((weight) => Math.max(0, Math.min(100, Math.round(completionRate * weight)))); +} + +function buildFocusDistribution(habits, tasks, doneHabits, doneTasks) { + const habitCompletion = habits.length ? Math.round((doneHabits / habits.length) * 100) : 0; + const taskCompletion = tasks.length ? Math.round((doneTasks / tasks.length) * 100) : 0; + const learningHabits = habits.filter((habit) => habit.category === 'Learning').length; + + const deepWork = Math.max(10, Math.min(60, Math.round(habitCompletion * 0.5))); + const meetings = Math.max(0, Math.min(30, Math.round((100 - taskCompletion) * 0.25))); + const learning = Math.max(0, Math.min(25, habits.length ? Math.round((learningHabits / habits.length) * 100) : 0)); + const admin = Math.max(0, 100 - deepWork - meetings - learning); + + return [ + ['Deep Work', deepWork, TOKENS.accent], + ['Meetings', meetings, TOKENS.energy], + ['Learning', learning, TOKENS.success], + ['Admin', admin, TOKENS.warn], + ]; +} + +function buildMonthlyOverview(habits, tasks, profile) { + const totalSignals = habits.length + tasks.length + Math.round((profile?.focusMinutes ?? 0) / 30); + + if (totalSignals <= 0) { + return Array(30).fill(0); + } + + return Array.from({ length: 30 }, (_, index) => ((index * 17 + totalSignals) % 100) / 100); } \ No newline at end of file diff --git a/src/pages/SettingsPage.jsx b/src/pages/SettingsPage.jsx index 471e0fc..b7b4963 100644 --- a/src/pages/SettingsPage.jsx +++ b/src/pages/SettingsPage.jsx @@ -1,13 +1,17 @@ import { useState } from 'react'; import { TOKENS } from '../data/appData'; import { Avatar, Button, Card, SectionTitle, Tag } from '../components/ui'; +import { calculateXp } from '../lib/accountMetrics'; -export default function SettingsPage({ user, profile, setProfile }) { +export default function SettingsPage({ user, profile, setProfile, habits = [], tasks = [] }) { const [prefs, setPrefs] = useState({ notifications: true, sounds: true, aiCoach: true, weeklyReport: true, darkMode: true, focusDuration: 25, breakDuration: 5 }); const joinDate = user?.createdAt ? new Date(user.createdAt) : null; const joinLabel = joinDate ? joinDate.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) : 'Unknown'; const focusDuration = profile?.focusDuration ?? 25; const breakDuration = profile?.breakDuration ?? 5; + // FIX: use real XP instead of hardcoded level 8 + const xp = calculateXp(habits, tasks); + const levelTitle = xp.level >= 20 ? 'Legend' : xp.level >= 10 ? 'Expert' : xp.level >= 5 ? 'Adept' : xp.level >= 2 ? 'Apprentice' : 'Beginner'; const toggle = (key) => setPrefs((current) => ({ ...current, [key]: !current[key] })); @@ -16,6 +20,7 @@ export default function SettingsPage({ user, profile, setProfile }) { setProfile((current) => ({ ...(current ?? {}), [key]: value, + ...(key === 'focusDuration' ? { focusDurationSeconds: value * 60 } : null), })); }; @@ -30,7 +35,7 @@ export default function SettingsPage({ user, profile, setProfile }) {
{user?.name ?? 'Alex Kumar'}
{user?.email ?? 'alex@example.com'}
- Legend · Level 8 + {levelTitle} · Level {xp.level} Joined {joinLabel}
@@ -54,7 +59,7 @@ export default function SettingsPage({ user, profile, setProfile }) {
Timer Settings
{[ - ['Focus Duration', 'focusDuration', 15, 60, 'min'], + ['Focus Session', 'focusDuration', 15, 60, 'min'], ['Short Break', 'breakDuration', 1, 15, 'min'], ].map(([label, key, min, max, unit]) => (