Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion public/media/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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`.
Binary file added public/media/alert.mp3
Binary file not shown.
4 changes: 2 additions & 2 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,8 @@ export default function App() {
{activePage === 'tasks' ? <TasksPage {...pageProps} /> : null}
{activePage === 'insights' ? <InsightsPage habits={habits} tasks={tasks} user={user} profile={profile} /> : null}
{activePage === 'social' ? <SocialPage /> : null}
{activePage === 'achievements' ? <AchievementsPage /> : null}
{activePage === 'settings' ? <SettingsPage user={user} profile={profile} setProfile={setProfile} /> : null}
{activePage === 'achievements' ? <AchievementsPage habits={habits} tasks={tasks} profile={profile} /> : null}
{activePage === 'settings' ? <SettingsPage user={user} profile={profile} setProfile={setProfile} habits={habits} tasks={tasks} /> : null}
</AppShell>
);
}
Expand Down
1 change: 1 addition & 0 deletions src/data/appData.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,5 +85,6 @@ export const defaultProfile = (user) => ({
isDemo: Boolean(user?.isDemo),
focusMinutes: 0,
focusDuration: 25,
focusDurationSeconds: 25 * 60,
breakDuration: 5,
});
141 changes: 107 additions & 34 deletions src/pages/AchievementsPage.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="app-page" style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
Expand All @@ -13,52 +16,122 @@ export default function AchievementsPage() {
<Card style={{ padding: '20px 22px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
<div>
<div style={{ fontFamily: 'Space Grotesk, sans-serif', fontSize: 22, fontWeight: 700 }}>Level {level} · Legend</div>
<div style={{ fontSize: 13, color: TOKENS.muted, marginTop: 4 }}>{currentXp.toLocaleString()} XP · {levelGoal - currentXp} to Level {level + 1}</div>
<div style={{ fontFamily: 'Space Grotesk, sans-serif', fontSize: 22, fontWeight: 700 }}>
Level {xp.level} · {getLevelTitle(xp.level)}
</div>
<div style={{ fontSize: 13, color: TOKENS.muted, marginTop: 4 }}>
{xp.total.toLocaleString()} XP · {xp.progressToNextLevel} to Level {xp.level + 1}
</div>
</div>
<div style={{ fontSize: 34 }}>🌟</div>
<div style={{ fontSize: 34 }}>{getLevelEmoji(xp.level)}</div>
</div>
<div style={{ marginTop: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: TOKENS.muted, marginBottom: 6 }}>
<span>Level {level}</span>
<span>Level {level + 1}</span>
<span>Level {xp.level}</span>
<span>Level {xp.level + 1}</span>
</div>
<ProgressBar value={xp.progressInLevel} total={XP_PER_LEVEL} color={TOKENS.warn} height={8} />
<div style={{ fontSize: 11, color: TOKENS.muted, marginTop: 6 }}>
{Math.round(xp.percentToNextLevel)}% complete
</div>
<ProgressBar value={currentXp} total={levelGoal} color={TOKENS.warn} height={8} />
<div style={{ fontSize: 11, color: TOKENS.muted, marginTop: 6 }}>{Math.round((currentXp / levelGoal) * 100)}% complete</div>
</div>
</Card>

<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<StatCard label="Badges Earned" value={`${badges.filter((badge) => badge.earned).length}`} color={TOKENS.warn} />
<StatCard label="Total XP" value="12.4k" color={TOKENS.accent} />
<StatCard label="Challenges Won" value="8" color={TOKENS.success} />
<StatCard label="Badges Earned" value={`${earnedBadges}`} color={TOKENS.warn} />
<StatCard label="Total XP" value={xp.total >= 1000 ? `${(xp.total / 1000).toFixed(1)}k` : String(xp.total)} color={TOKENS.accent} />
<StatCard label="Focus Min" value={`${profile?.focusMinutes ?? 0}`} color={TOKENS.success} />
</div>

<Card>
<div style={{ fontFamily: 'Space Grotesk, sans-serif', fontWeight: 700, fontSize: 16, marginBottom: 16 }}>Badges</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: 10 }}>
{badges.map((badge) => (
<div key={badge.name} style={{ padding: 16, borderRadius: 16, background: badge.earned ? `${badge.color}14` : TOKENS.card2, border: `1px solid ${badge.earned ? `${badge.color}44` : TOKENS.border}`, opacity: badge.earned ? 1 : 0.55, textAlign: 'center' }}>
<div style={{ fontSize: 32, marginBottom: 10, filter: badge.earned ? 'none' : 'grayscale(1)' }}>{badge.icon}</div>
<div style={{ fontSize: 13, fontWeight: 700, marginBottom: 4 }}>{badge.name}</div>
<div style={{ fontSize: 11, color: TOKENS.muted }}>{badge.desc}</div>
{badge.earned ? <div style={{ marginTop: 10 }}><Tag color={badge.color}>Earned ✓</Tag></div> : null}
</div>
))}
</div>
{badges.length === 0 ? (
<div style={{ color: TOKENS.muted, fontSize: 13 }}>Complete habits and tasks to unlock badges.</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: 10 }}>
{badges.map((badge) => {
const unlocked = isBadgeUnlocked(badge, habits, tasks, profile);
return (
<div
key={badge.name}
style={{
padding: 16,
borderRadius: 16,
background: unlocked ? `${badge.color}14` : TOKENS.card2,
border: `1px solid ${unlocked ? `${badge.color}44` : TOKENS.border}`,
opacity: unlocked ? 1 : 0.55,
textAlign: 'center',
}}
>
<div style={{ fontSize: 32, marginBottom: 10, filter: unlocked ? 'none' : 'grayscale(1)' }}>{badge.icon}</div>
<div style={{ fontSize: 13, fontWeight: 700, marginBottom: 4 }}>{badge.name}</div>
<div style={{ fontSize: 11, color: TOKENS.muted }}>{badge.desc}</div>
{unlocked ? <div style={{ marginTop: 10 }}><Tag color={badge.color}>Earned ✓</Tag></div> : null}
</div>
);
})}
</div>
)}
</Card>

<Card>
<div style={{ fontFamily: 'Space Grotesk, sans-serif', fontWeight: 700, fontSize: 16, marginBottom: 14 }}>XP History - Last 7 Days</div>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 10, minHeight: 112 }}>
{[180, 240, 120, 300, 210, 280, 190].map((value, index) => (
<div key={index} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 7 }}>
<div style={{ width: '100%', height: `${(value / 300) * 82}px`, background: `${TOKENS.warn}66`, borderRadius: '10px 10px 4px 4px' }} />
<div style={{ fontSize: 10, color: TOKENS.muted }}>{MONTHS[index % 12]}</div>
</div>
))}
</div>
<div style={{ fontFamily: 'Space Grotesk, sans-serif', fontWeight: 700, fontSize: 16, marginBottom: 14 }}>XP Breakdown</div>
{xp.total === 0 ? (
<div style={{ color: TOKENS.muted, fontSize: 13 }}>Complete habits and tasks to earn XP.</div>
) : (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 10, minHeight: 112 }}>
{[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 (
<div key={index} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 7 }}>
<div style={{ width: '100%', height: `${height}px`, background: `${TOKENS.warn}66`, borderRadius: '10px 10px 4px 4px' }} />
<div style={{ fontSize: 10, color: TOKENS.muted }}>{MONTHS[index % 12]}</div>
</div>
);
})}
</div>
)}
</Card>
</div>
);
}
}

// 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 '🌱';
}
62 changes: 43 additions & 19 deletions src/pages/DashboardPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="app-page" style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<SectionTitle
title={`Good morning, ${displayName}`}
title={`${greeting}, ${displayName}`}
subtitle="Your habits, focus, and tasks are all in one place."
action={(
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<Tag color={TOKENS.warn}>🔥 14 streak</Tag>
{/* FIX: only show streak tag if there is actually a streak */}
{maxStreak > 0 && <Tag color={TOKENS.warn}>🔥 {maxStreak} streak</Tag>}
<Avatar name={displayName} size={40} />
</div>
)}
/>

<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<StatCard label="Focus Hours" value={focusHours} color={TOKENS.accent} sub="All-time pomodoro time" />
<StatCard label="Focus Hours" value={focusHours} color={TOKENS.accent} sub="All-time focus session time" />
<StatCard label="Habits Done" value={habitSummary} color={TOKENS.success} sub={habits.length ? 'Daily completion' : 'Add your first habit'} />
<StatCard label="Tasks" value={taskSummary} color={TOKENS.warn} sub={tasks.length ? 'Tasks finished' : 'Add your first task'} />
<StatCard
label="XP Today"
value={`+${xp.total}`}
// FIX: show 0 XP for new accounts with no completed habits/tasks
value={xp.total > 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'}
/>
</div>

Expand All @@ -44,7 +53,9 @@ export default function DashboardPage({ habits, tasks, setPage, user, profile })
</div>

<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{habits.length === 0 ? <div style={{ color: TOKENS.muted, fontSize: 13, lineHeight: 1.6 }}>No habits yet. Add one to start tracking your streaks.</div> : null}
{habits.length === 0 ? (
<div style={{ color: TOKENS.muted, fontSize: 13, lineHeight: 1.6 }}>No habits yet. Add one to start tracking your streaks.</div>
) : 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;
Expand All @@ -70,11 +81,13 @@ export default function DashboardPage({ habits, tasks, setPage, user, profile })
</div>

<div style={{ textAlign: 'center', padding: '8px 0 6px' }}>
<div style={{ fontSize: 56, fontFamily: 'Space Grotesk, sans-serif', fontWeight: 700, color: TOKENS.accent, letterSpacing: '-0.05em' }}>25:00</div>
<div style={{ marginTop: 6, fontSize: 12, color: TOKENS.muted }}>Pomodoro · Deep Work</div>
<div style={{ fontSize: 56, fontFamily: 'Space Grotesk, sans-serif', fontWeight: 700, color: TOKENS.accent, letterSpacing: '-0.05em' }}>
{formatClock(focusDurationSeconds)}
</div>
<div style={{ marginTop: 6, fontSize: 12, color: TOKENS.muted }}>Focus Session · Deep Work</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', marginTop: 16 }}>
{[1, 2, 3, 4].map((index) => (
<div key={index} style={{ width: 8, height: 8, borderRadius: '50%', background: index <= 2 ? TOKENS.accent : TOKENS.card2 }} />
<div key={index} style={{ width: 8, height: 8, borderRadius: '50%', background: TOKENS.card2 }} />
))}
</div>
</div>
Expand All @@ -86,16 +99,27 @@ export default function DashboardPage({ habits, tasks, setPage, user, profile })
<div style={{ fontFamily: 'Space Grotesk, sans-serif', fontWeight: 700, fontSize: 16 }}>Focus Hours</div>
<Tag color={TOKENS.accent}>This week</Tag>
</div>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 10, minHeight: 128 }}>
{[2.5, 4.1, 1.8, 5.0, 3.2, 4.5, 3.2].map((value, index) => (
<div key={index} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 7 }}>
<div style={{ fontSize: 10, color: TOKENS.muted }}>{value}h</div>
<div style={{ width: '100%', height: `${(value / 5) * 84}px`, borderRadius: '10px 10px 4px 4px', background: index === 6 ? `linear-gradient(180deg, ${TOKENS.accent}, ${TOKENS.accentHover})` : `${TOKENS.accent}55` }} />
<div style={{ fontSize: 10, color: TOKENS.muted }}>{DAYS[index]}</div>
</div>
))}
</div>
{(profile?.focusMinutes ?? 0) === 0 ? (
<div style={{ color: TOKENS.muted, fontSize: 13, padding: '8px 0' }}>No focus sessions yet. Start a Focus Session to log time here.</div>
) : (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 10, minHeight: 128 }}>
{[2.5, 4.1, 1.8, 5.0, 3.2, 4.5, 3.2].map((value, index) => (
<div key={index} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 7 }}>
<div style={{ fontSize: 10, color: TOKENS.muted }}>{value}h</div>
<div style={{ width: '100%', height: `${(value / 5) * 84}px`, borderRadius: '10px 10px 4px 4px', background: index === 6 ? `linear-gradient(180deg, ${TOKENS.accent}, ${TOKENS.accentHover})` : `${TOKENS.accent}55` }} />
<div style={{ fontSize: 10, color: TOKENS.muted }}>{DAYS[index]}</div>
</div>
))}
</div>
)}
</Card>
</div>
);
}
}

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')}`;
}
Loading
Loading