import React, { useState, useEffect, useMemo } from 'react';
import {
Leaf, Droplet, Sun, CheckCircle2, Circle, Scale,
Trees, TrendingDown, TrendingUp, Award, Wind,
Plus, User, Settings, Trash2, X, ChevronRight, Activity, Calendar, Target,
AlertTriangle
} from 'lucide-react';
// --- Toast 通知元件 ---
const Toast = ({ message, type, onClose }) => {
useEffect(() => {
const timer = setTimeout(onClose, 3000);
return () => clearTimeout(timer);
}, [onClose]);
return (
{type === 'success' &&
}
{type === 'reward' &&
}
{type === 'error' &&
}
{message}
);
};
// --- SVG 折線圖元件 ---
const LineChart = ({ data }) => {
if (data.length === 0) return (
還沒有足夠的數據畫圖表喔 🌱
);
// 整理資料:按照日期由舊到新排序
const sortedData = [...data].sort((a, b) => new Date(a.dateStr) - new Date(b.dateStr));
const width = 300;
const height = 150;
const padding = 20;
const minWeight = Math.floor(Math.min(...sortedData.map(d => d.weight))) - 1;
const maxWeight = Math.ceil(Math.max(...sortedData.map(d => d.weight))) + 1;
const weightRange = maxWeight - minWeight || 1; // 避免除以 0
const getX = (index) => padding + (index * ((width - padding * 2) / Math.max(1, sortedData.length - 1)));
const getY = (weight) => height - padding - ((weight - minWeight) / weightRange) * (height - padding * 2);
const points = sortedData.map((d, i) => `${getX(i)},${getY(d.weight)}`).join(' ');
const areaPath = `M ${getX(0)},${height - padding} L ${points} L ${getX(sortedData.length - 1)},${height - padding} Z`;
return (
體重變化趨勢
);
};
export default function App() {
// --- 狀態管理 (使用新 key 避免舊資料衝突) ---
const [isInitialized, setIsInitialized] = useState(false);
const [activeTab, setActiveTab] = useState('tree');
const [toast, setToast] = useState(null);
// 彈出視窗狀態
const [showSettings, setShowSettings] = useState(false);
const [showResetConfirm, setShowResetConfirm] = useState(false);
// 取得今天日期的 YYYY-MM-DD 格式
const getTodayStr = () => {
const today = new Date();
return today.toISOString().split('T')[0];
};
const [logDate, setLogDate] = useState(getTodayStr());
// 用戶資料 (新增目標日期)
const [userProfile, setUserProfile] = useState(() => {
const saved = localStorage.getItem('td_v2_userProfile');
return saved ? JSON.parse(saved) : {
initialWeight: '',
targetWeight: '',
startDate: getTodayStr(),
targetDate: '',
hasOnboarded: false
};
});
// 樹木與森林狀態
const maxExp = 100;
const [treeState, setTreeState] = useState(() => {
const saved = localStorage.getItem('td_v2_treeState');
return saved ? JSON.parse(saved) : { level: 1, exp: 0 };
});
const [forest, setForest] = useState(() => {
const saved = localStorage.getItem('td_v2_forest');
return saved ? JSON.parse(saved) : [];
});
// 任務狀態
const [tasks, setTasks] = useState(() => {
const saved = localStorage.getItem('td_v2_tasks');
return saved ? JSON.parse(saved) : [
{ id: 1, title: '喝水 2000cc', exp: 15, completed: false, type: 'water' },
{ id: 2, title: '運動 30 分鐘', exp: 20, completed: false, type: 'sun' },
{ id: 3, title: '拒絕含糖飲料', exp: 15, completed: false, type: 'wind' },
];
});
// 體重紀錄 [{ id, weight, dateStr, displayDate }]
const [weightLogs, setWeightLogs] = useState(() => {
const saved = localStorage.getItem('td_v2_weightLogs');
return saved ? JSON.parse(saved) : [];
});
const [currentWeightInput, setCurrentWeightInput] = useState('');
const [newTaskInput, setNewTaskInput] = useState('');
const [showAddTask, setShowAddTask] = useState(false);
// --- 資料同步 ---
useEffect(() => {
localStorage.setItem('td_v2_userProfile', JSON.stringify(userProfile));
localStorage.setItem('td_v2_treeState', JSON.stringify(treeState));
localStorage.setItem('td_v2_forest', JSON.stringify(forest));
localStorage.setItem('td_v2_tasks', JSON.stringify(tasks));
localStorage.setItem('td_v2_weightLogs', JSON.stringify(weightLogs));
setIsInitialized(true);
}, [userProfile, treeState, forest, tasks, weightLogs]);
const showToast = (message, type = 'success') => setToast({ message, type });
// --- 每日計畫邏輯計算 ---
const planStats = useMemo(() => {
if (!userProfile.hasOnboarded) return null;
const start = new Date(userProfile.startDate);
const target = new Date(userProfile.targetDate);
const today = new Date();
// 將時間清零以準確計算天數
start.setHours(0,0,0,0);
target.setHours(0,0,0,0);
today.setHours(0,0,0,0);
const totalDays = Math.max(1, Math.round((target - start) / (1000 * 60 * 60 * 24)));
const daysPassed = Math.max(0, Math.round((today - start) / (1000 * 60 * 60 * 24)));
const daysLeft = Math.max(0, totalDays - daysPassed);
const totalToLose = userProfile.initialWeight - userProfile.targetWeight;
const dailyLossGoal = totalToLose / totalDays;
// 計算「今日應該要達到的體重」
const expectedWeightToday = userProfile.initialWeight - (dailyLossGoal * daysPassed);
// 取得最新一筆體重(如果還沒記錄,就用初始體重)
const sortedLogs = [...weightLogs].sort((a, b) => new Date(b.dateStr) - new Date(a.dateStr));
const currentWeight = sortedLogs.length > 0 ? sortedLogs[0].weight : userProfile.initialWeight;
// 與今日目標的差距 (負數代表超前,正數代表落後)
const diffFromTodayGoal = (currentWeight - expectedWeightToday).toFixed(1);
return { totalDays, daysPassed, daysLeft, expectedWeightToday: expectedWeightToday.toFixed(1), currentWeight, diffFromTodayGoal };
}, [userProfile, weightLogs]);
// --- 樹木邏輯 ---
const getTreeVisual = (level) => {
switch(level) {
case 1: return { emoji: '🌱', name: '小幼苗', desc: '剛發芽,需要你的細心呵護。' };
case 2: return { emoji: '🌿', name: '生長中', desc: '長出綠葉了!繼續保持好習慣。' };
case 3: return { emoji: '🪴', name: '小樹叢', desc: '越來越茁壯,你的身體也是!' };
case 4: return { emoji: '🌳', name: '大樹', desc: '枝繁葉茂,快要完全長成了!' };
default: return { emoji: '🌲', name: '神木', desc: '即將移入森林,準備迎接新生命!' };
}
};
const currentTree = getTreeVisual(treeState.level);
const addExp = (amount) => {
let newExp = treeState.exp + amount;
let newLevel = treeState.level;
if (newExp >= maxExp) {
if (newLevel < 5) {
newLevel += 1;
newExp -= maxExp;
showToast(`升級了!小樹成長為 Lv.${newLevel}!`, 'success');
} else {
const treeTypes = ['🌲', '🌳', '🌴', '🌵', '🌸', '🍁'];
const randomTree = treeTypes[Math.floor(Math.random() * treeTypes.length)];
const newTree = { id: Date.now(), type: randomTree, date: new Date().toLocaleDateString() };
setForest([newTree, ...forest]);
newLevel = 1;
newExp -= maxExp;
showToast(`太棒了!成功種植一棵 ${randomTree},已移入森林!`, 'reward');
}
}
setTreeState({ level: newLevel, exp: newExp });
};
// --- 互動操作 ---
const toggleTask = (id) => {
setTasks(tasks.map(task => {
if (task.id === id) {
if (!task.completed) {
addExp(task.exp);
showToast(`完成任務!獲得 ${task.exp} 養分`, 'success');
}
return { ...task, completed: !task.completed };
}
return task;
}));
};
const addTask = (e) => {
e.preventDefault();
if (!newTaskInput.trim()) return;
setTasks([...tasks, { id: Date.now(), title: newTaskInput, exp: 15, completed: false, type: 'sun' }]);
setNewTaskInput('');
setShowAddTask(false);
showToast('已新增專屬任務!');
};
const handleLogWeight = (e) => {
e.preventDefault();
if (!currentWeightInput || isNaN(currentWeightInput)) return;
const weightNum = parseFloat(currentWeightInput);
// 檢查同一天是否已經有紀錄,有的話覆蓋
const existingIndex = weightLogs.findIndex(log => log.dateStr === logDate);
let newLogs = [...weightLogs];
let isWeightDown = false;
let expBonus = 15;
// 找前一次的體重來對比 (找日期比當前輸入日期小的最新一筆)
const pastLogs = newLogs.filter(log => new Date(log.dateStr) < new Date(logDate)).sort((a,b) => new Date(b.dateStr) - new Date(a.dateStr));
const lastWeight = pastLogs.length > 0 ? pastLogs[0].weight : userProfile.initialWeight;
if (weightNum < lastWeight) {
expBonus += 35;
isWeightDown = true;
}
const logEntry = {
id: existingIndex >= 0 ? newLogs[existingIndex].id : Date.now(),
weight: weightNum,
dateStr: logDate,
displayDate: new Date(logDate).toLocaleDateString('zh-TW', { month: 'short', day: 'numeric' })
};
if (existingIndex >= 0) {
newLogs[existingIndex] = logEntry;
showToast('已更新該日體重紀錄!', 'success');
} else {
newLogs.push(logEntry);
addExp(expBonus);
if (isWeightDown) showToast(`體重下降!獲得超級肥料 (+${expBonus} 養分) 🌱✨`, 'reward');
else showToast(`記錄成功!獲得日常澆水 (+${expBonus} 養分) 💧`, 'success');
}
setWeightLogs(newLogs);
setCurrentWeightInput('');
};
const handleOnboard = (e) => {
e.preventDefault();
const initW = parseFloat(e.target.initW.value);
const targetW = parseFloat(e.target.targetW.value);
const targetD = e.target.targetD.value;
if (initW <= targetW) {
showToast('目標體重必須小於目前體重喔!', 'error');
return;
}
if (new Date(targetD) <= new Date()) {
showToast('目標日期必須在今天之後喔!', 'error');
return;
}
setUserProfile({
initialWeight: initW,
targetWeight: targetW,
startDate: getTodayStr(),
targetDate: targetD,
hasOnboarded: true
});
showToast('歡迎來到小樹減肥法!');
};
const handleUpdateProfile = (e) => {
e.preventDefault();
const targetW = parseFloat(e.target.editTargetW.value);
const targetD = e.target.editTargetD.value;
if (new Date(targetD) <= new Date(userProfile.startDate)) {
showToast('目標日期必須大於開始日期!', 'error');
return;
}
setUserProfile({ ...userProfile, targetWeight: targetW, targetDate: targetD });
setShowSettings(false);
showToast('設定已更新!', 'success');
};
const handleHardReset = () => {
localStorage.removeItem('td_v2_userProfile');
localStorage.removeItem('td_v2_treeState');
localStorage.removeItem('td_v2_forest');
localStorage.removeItem('td_v2_tasks');
localStorage.removeItem('td_v2_weightLogs');
window.location.reload();
};
// --- 渲染 ---
if (!isInitialized) return null;
// Onboarding
if (!userProfile.hasOnboarded) {
return (
{toast &&
setToast(null)} />}
🌱
小樹減肥法
每天一點點進步,種出你的專屬森林。
設定一個具體的目標吧!
);
}
// 將 weightLogs 排序以供顯示
const sortedDisplayLogs = [...weightLogs].sort((a, b) => new Date(b.dateStr) - new Date(a.dateStr));
return (
{toast &&
setToast(null)} />}
{/* 頂部導航 */}
小樹減肥法
Lv.{treeState.level}
{/* 主要內容區 */}
{/* Tab 1: 小樹養成 */}
{activeTab === 'tree' && (
{/* 每日計畫小看板 */}
目標倒數 {planStats.daysLeft} 天
今日目標: {planStats.expectedWeightToday} kg
{planStats.currentWeight}
kg
addExp(1)}
>
{currentTree.emoji}
{currentTree.name}
{currentTree.desc}
成長進度
{treeState.exp} / {maxExp} 養分
)}
{/* Tab 2: 每日任務 */}
{activeTab === 'tasks' && (
{showAddTask && (
)}
{tasks.map(task => (
toggleTask(task.id)}>
{task.completed ? : }
{task.title}
+{task.exp} 養分
))}
)}
{/* Tab 3: 體重紀錄 */}
{activeTab === 'weight' && (
{/* 折線圖 */}
{/* 輸入區塊 (優化版面) */}
{/* 背景裝飾 */}
填寫體重紀錄
{/* 日期選擇器改為精緻的徽章樣式,放在右上角 */}
歷史紀錄
{sortedDisplayLogs.length === 0 ? (
還沒有記錄喔,趕快站上體重計吧!
) : (
{sortedDisplayLogs.map((log, index) => {
const prevLog = sortedDisplayLogs[index + 1];
let diff = 0;
if (prevLog) diff = (log.weight - prevLog.weight).toFixed(1);
else diff = (log.weight - userProfile.initialWeight).toFixed(1);
return (
{log.dateStr}
{diff !== "0.0" && (
{diff < 0 ? : }
{Math.abs(diff)}
)}
{log.weight}
);
})}
)}
)}
{/* Tab 4: 森林與設定 */}
{activeTab === 'profile' && (
我的成就
初始: {userProfile.initialWeight}kg ➔ 目標: {userProfile.targetWeight}kg
已減去
{Math.max(0, (userProfile.initialWeight - planStats.currentWeight)).toFixed(1)} kg
我的專屬森林
{forest.map(tree => (
{tree.type}
{tree.date}
))}
🌱
繼續努力
{/* 系統設定按鈕群 */}
)}
{/* 底部導航列 */}
{/* --- 彈出視窗 (Modals) --- */}
{/* 1. 修改設定 Modal */}
{showSettings && (
修改目標
)}
{/* 2. 重置確認 Modal */}
{showResetConfirm && (
確定要放棄整座森林嗎?
此操作將會刪除你所有的樹木、體重紀錄與設定,且無法復原。真的要重頭來過嗎?
)}
);
}
function NavItem({ icon, label, active, onClick }) {
return (
);
}