diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd3dbb5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/README.md b/README.md index ba38467..2b3e915 100644 --- a/README.md +++ b/README.md @@ -1 +1,119 @@ -# Lifestock \ No newline at end of file +# LifeStock Dashboard + +A Next.js 14 dashboard that tracks your life pillars (Knowledge, Skills, Health, Discipline, Mood) as if they were moving a stock price. Features real-time candlestick charts, daily entries, and weekly performance reports. + +## Features + +- 👤 Personalized user name on first launch +- 📊 Real-time candlestick chart visualization +- 📝 Daily pillar tracking (0-10 scale for each pillar) +- 📈 Automatic price calculations based on pillar averages +- 📅 Weekly earnings reports with streak tracking +- 💾 Local data persistence using localStorage +- 🌙 Dark theme UI with responsive design +- 📱 Mobile and desktop friendly + +## Tech Stack + +- **Next.js 14** with App Router +- **TypeScript** for type safety +- **Tailwind CSS** for styling +- **lightweight-charts** for candlestick visualization +- **localStorage** for data persistence + +## Installation + +1. Clone the repository: +```bash +git clone https://github.com/kyzen-dev7/Lifestock.git +cd Lifestock +``` + +2. Install dependencies: +```bash +npm install +``` + +3. Run the development server: +```bash +npm run dev +``` + +4. Open [http://localhost:3000](http://localhost:3000) in your browser. + +## Building for Production + +Build the application: +```bash +npm run build +``` + +Start the production server: +```bash +npm start +``` + +## How It Works + +### The Formula + +1. **Average Calculation**: `Avg = (Knowledge + Skills + Health + Discipline + Mood) / 5` +2. **Daily Change**: `DailyChange% = (Avg - 5) * 4` + - When Avg = 10 → +20% + - When Avg = 5 → 0% + - When Avg = 0 → -20% +3. **Price Calculation**: `NewClose = OldClose * (1 + DailyChange%/100)` +4. **Candlestick Wicks**: Discipline affects volatility + - Higher discipline = smaller wicks + - Lower discipline = larger wicks + +### Market Trends + +- **Bull Market**: Average ≥ 6.5 +- **Stable Market**: 4.8 ≤ Average ≤ 6.4 +- **Bear Market**: Average < 4.8 + +## Pages + +### Dashboard (/) +- Real-time candlestick chart +- Daily entry form with 5 pillar sliders +- Preview of projected price changes +- Recent entries table +- Current market trend indicator + +### Weekly Report (/weekly) +- Last 7 days performance summary +- Weekly % change +- Best and weakest pillar analysis +- Green streak counter (consecutive days with Avg ≥ 5) +- Biggest up/down days + +## Data Storage + +All data is stored locally in your browser's localStorage. No backend or external API required. + +## Project Structure + +``` +Lifestock/ +├── app/ +│ ├── layout.tsx # Root layout +│ ├── page.tsx # Dashboard page +│ ├── weekly/ +│ │ └── page.tsx # Weekly report page +│ └── globals.css # Global styles +├── components/ +│ └── Chart.tsx # Candlestick chart component +├── lib/ +│ └── lifestock.ts # Utility functions and formulas +├── package.json +├── tsconfig.json +├── tailwind.config.ts +├── postcss.config.js +└── next.config.js +``` + +## License + +MIT \ No newline at end of file diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..1181442 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,20 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --background: #000000; + --foreground: #ffffff; +} + +body { + color: var(--foreground); + background: var(--background); + font-family: Arial, Helvetica, sans-serif; +} + +@layer utilities { + .text-balance { + text-wrap: balance; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..6e5cf90 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "LifeStock Dashboard", + description: "Track your life pillars as a stock portfolio", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..e679f7a --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,349 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import Chart from '@/components/Chart'; +import { + DailyEntry, + Pillars, + loadEntries, + saveEntries, + todayISO, + createEntry, + getMarketTrend, + getTrendColor, + round2, + calculateAvg, + calculateDailyChangePct, + calculateNewClose, + getUserName, + saveUserName, +} from '@/lib/lifestock'; + +export default function Dashboard() { + const [entries, setEntries] = useState([]); + const [selectedDate, setSelectedDate] = useState(todayISO()); + const [pillars, setPillars] = useState({ + knowledge: 5, + skills: 5, + health: 5, + discipline: 5, + mood: 5, + }); + const [mounted, setMounted] = useState(false); + const [userName, setUserName] = useState(null); + const [nameInput, setNameInput] = useState(''); + + // Load entries and user name on mount + useEffect(() => { + setMounted(true); + const loaded = loadEntries(); + setEntries(loaded); + + const savedName = getUserName(); + setUserName(savedName); + + // If there's an entry for the selected date, load its values + const existingEntry = loaded.find(e => e.dateISO === selectedDate); + if (existingEntry) { + setPillars(existingEntry.pillars); + } + }, []); + + // Update pillars when date changes + useEffect(() => { + if (!mounted) return; + const existingEntry = entries.find(e => e.dateISO === selectedDate); + if (existingEntry) { + setPillars(existingEntry.pillars); + } else { + // Reset to default + setPillars({ + knowledge: 5, + skills: 5, + health: 5, + discipline: 5, + mood: 5, + }); + } + }, [selectedDate, entries, mounted]); + + const handleSaveDay = () => { + // Get previous close + const sortedEntries = [...entries].sort((a, b) => a.dateISO.localeCompare(b.dateISO)); + + // Find the entry just before the selected date + const beforeEntries = sortedEntries.filter(e => e.dateISO < selectedDate); + const previousClose = beforeEntries.length > 0 + ? beforeEntries[beforeEntries.length - 1].priceClose + : 100; + + // Create new entry + const newEntry = createEntry(selectedDate, pillars, previousClose); + + // Remove existing entry with same date + const filteredEntries = entries.filter(e => e.dateISO !== selectedDate); + + // Add new entry + const updatedEntries = [...filteredEntries, newEntry]; + + // Recalculate all entries after this date + const sorted = updatedEntries.sort((a, b) => a.dateISO.localeCompare(b.dateISO)); + const recalculated: DailyEntry[] = []; + + for (let i = 0; i < sorted.length; i++) { + if (i === 0) { + // First entry + const entry = createEntry(sorted[i].dateISO, sorted[i].pillars, 100); + recalculated.push(entry); + } else { + // Use previous close + const entry = createEntry(sorted[i].dateISO, sorted[i].pillars, recalculated[i - 1].priceClose); + recalculated.push(entry); + } + } + + setEntries(recalculated); + saveEntries(recalculated); + }; + + const handleReset = () => { + if (confirm('Are you sure you want to clear all data? This cannot be undone.')) { + setEntries([]); + saveEntries([]); + } + }; + + const handleNameSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (nameInput.trim()) { + saveUserName(nameInput.trim()); + setUserName(nameInput.trim()); + } + }; + + // Get latest entry for display + const latestEntry = entries.length > 0 + ? [...entries].sort((a, b) => b.dateISO.localeCompare(a.dateISO))[0] + : null; + + // Calculate preview values + const previewAvg = round2(calculateAvg(pillars)); + const previewDailyChangePct = round2(calculateDailyChangePct(previewAvg)); + + const sortedEntries = [...entries].sort((a, b) => a.dateISO.localeCompare(b.dateISO)); + const beforeEntries = sortedEntries.filter(e => e.dateISO < selectedDate); + const previewOpen = beforeEntries.length > 0 + ? beforeEntries[beforeEntries.length - 1].priceClose + : 100; + const previewClose = round2(calculateNewClose(previewOpen, previewDailyChangePct)); + + // Recent entries (last 10-12) + const recentEntries = [...entries] + .sort((a, b) => b.dateISO.localeCompare(a.dateISO)) + .slice(0, 12); + + if (!mounted) { + return ( +
+
Loading...
+
+ ); + } + + // Show name input if no name is saved + if (!userName) { + return ( +
+
+
+

Welcome to LifeStock

+

+ Track your life pillars as a stock portfolio +

+
+ + setNameInput(e.target.value)} + placeholder="Your Name" + className="w-full bg-gray-800 border border-gray-700 rounded px-4 py-3 text-white mb-4 focus:outline-none focus:border-blue-500" + autoFocus + required + /> + +
+
+
+
+ ); + } + + return ( +
+
+ {/* Header */} +
+

+ LifeStock: {userName.toUpperCase()} LTD. ( + {userName.substring(0, 4).toUpperCase()}X) +

+
+
+ ${latestEntry ? latestEntry.priceClose.toFixed(2) : '100.00'} +
+ {latestEntry && ( +
+ {getMarketTrend(latestEntry.avg)} +
+ )} +
+
+ + {/* Chart */} +
+ +
+ + {/* Main content grid */} +
+ {/* Daily Entry Card */} +
+

Daily Entry

+ + {/* Date picker */} +
+ + setSelectedDate(e.target.value)} + className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white" + /> +
+ + {/* Pillar sliders */} +
+ {(['knowledge', 'skills', 'health', 'discipline', 'mood'] as const).map((pillar) => ( +
+
+ + {pillars[pillar]} +
+ setPillars({ ...pillars, [pillar]: Number(e.target.value) })} + className="w-full accent-blue-500" + /> +
+ ))} +
+ + {/* Save button */} + +
+ + {/* Preview Card */} +
+

Preview

+
+
+ Open + ${previewOpen.toFixed(2)} +
+
+ Close (Projected) + ${previewClose.toFixed(2)} +
+
+ Average + {previewAvg.toFixed(2)} +
+
+ Daily Change + = 0 ? 'text-green-500' : 'text-red-500'}`}> + {previewDailyChangePct >= 0 ? '+' : ''}{previewDailyChangePct.toFixed(2)}% + +
+
+
+ {getMarketTrend(previewAvg)} +
+
+
+
+
+ + {/* Recent entries */} +
+

Recent Days

+ {recentEntries.length === 0 ? ( +
No entries yet
+ ) : ( +
+ + + + + + + + + + + + {recentEntries.map((entry) => ( + + + + + + + + ))} + +
DateCloseChangeAvgTrend
{entry.dateISO}${entry.priceClose.toFixed(2)}= 0 ? 'text-green-500' : 'text-red-500'}`}> + {entry.dailyChangePct >= 0 ? '+' : ''}{entry.dailyChangePct.toFixed(2)}% + {entry.avg.toFixed(2)}
+
+ )} +
+ + {/* Actions */} +
+ + View Weekly Report + + +
+
+
+ ); +} diff --git a/app/weekly/page.tsx b/app/weekly/page.tsx new file mode 100644 index 0000000..0dc16a0 --- /dev/null +++ b/app/weekly/page.tsx @@ -0,0 +1,222 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { DailyEntry, loadEntries, round2 } from '@/lib/lifestock'; + +export default function WeeklyReport() { + const [entries, setEntries] = useState([]); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + const loaded = loadEntries(); + setEntries(loaded); + }, []); + + if (!mounted) { + return ( +
+
Loading...
+
+ ); + } + + // Get last 7 days (or fewer if not available) + const sortedEntries = [...entries].sort((a, b) => b.dateISO.localeCompare(a.dateISO)); + const last7Days = sortedEntries.slice(0, 7); + + if (last7Days.length === 0) { + return ( +
+
+

Weekly Earnings Report

+
+

No data available for weekly report

+ + Back to Dashboard + +
+
+
+ ); + } + + // Sort by date ascending for calculations + const weekData = [...last7Days].sort((a, b) => a.dateISO.localeCompare(b.dateISO)); + + // Weekly % change from first open to last close + const firstOpen = weekData[0].priceOpen; + const lastClose = weekData[weekData.length - 1].priceClose; + const weeklyChangePct = round2(((lastClose - firstOpen) / firstOpen) * 100); + + // Best and weakest pillar + const pillarAverages: Record = { + knowledge: 0, + skills: 0, + health: 0, + discipline: 0, + mood: 0, + }; + + weekData.forEach((entry) => { + pillarAverages.knowledge += entry.pillars.knowledge; + pillarAverages.skills += entry.pillars.skills; + pillarAverages.health += entry.pillars.health; + pillarAverages.discipline += entry.pillars.discipline; + pillarAverages.mood += entry.pillars.mood; + }); + + Object.keys(pillarAverages).forEach((key) => { + pillarAverages[key] = round2(pillarAverages[key] / weekData.length); + }); + + const sortedPillars = Object.entries(pillarAverages).sort((a, b) => b[1] - a[1]); + const bestPillar = sortedPillars[0]; + const weakestPillar = sortedPillars[sortedPillars.length - 1]; + + // Streak count: consecutive days with Avg >= 5 + let currentStreak = 0; + for (let i = weekData.length - 1; i >= 0; i--) { + if (weekData[i].avg >= 5) { + currentStreak++; + } else { + break; + } + } + + // Biggest up day and down day + const sortedByChange = [...weekData].sort((a, b) => b.dailyChangePct - a.dailyChangePct); + const biggestUpDay = sortedByChange[0]; + const biggestDownDay = sortedByChange[sortedByChange.length - 1]; + + return ( +
+
+ {/* Header */} +
+

Weekly Earnings Report

+

+ Last {weekData.length} day{weekData.length !== 1 ? 's' : ''} • {weekData[0].dateISO} to {weekData[weekData.length - 1].dateISO} +

+
+ + {/* Summary Cards */} +
+ {/* Weekly Change */} +
+

Weekly Change

+
= 0 ? 'text-green-500' : 'text-red-500'}`}> + {weeklyChangePct >= 0 ? '+' : ''}{weeklyChangePct.toFixed(2)}% +
+
+ ${firstOpen.toFixed(2)} → ${lastClose.toFixed(2)} +
+
+ + {/* Streak */} +
+

Green Streak

+
+ {currentStreak} day{currentStreak !== 1 ? 's' : ''} +
+
+ Consecutive days with Avg ≥ 5 +
+
+
+ + {/* Pillar Performance */} +
+

Pillar Performance

+
+
+
+ Best Pillar + {bestPillar[0]} +
+
+
+
+
+ {bestPillar[1].toFixed(1)} +
+
+ +
+
+ Weakest Pillar + {weakestPillar[0]} +
+
+
+
+
+ {weakestPillar[1].toFixed(1)} +
+
+
+ + {/* All pillars */} +
+

All Pillars (Weekly Avg)

+
+ {sortedPillars.map(([name, avg]) => ( +
+ {name} +
+
+
+ {avg.toFixed(1)} +
+ ))} +
+
+
+ + {/* Best and Worst Days */} +
+ {/* Biggest Up Day */} +
+

Biggest Up Day

+
+ +{biggestUpDay.dailyChangePct.toFixed(2)}% +
+
{biggestUpDay.dateISO}
+
Avg: {biggestUpDay.avg.toFixed(2)}
+
+ + {/* Biggest Down Day */} +
+

Biggest Down Day

+
+ {biggestDownDay.dailyChangePct.toFixed(2)}% +
+
{biggestDownDay.dateISO}
+
Avg: {biggestDownDay.avg.toFixed(2)}
+
+
+ + {/* Back button */} + + ← Back to Dashboard + +
+
+ ); +} diff --git a/components/Chart.tsx b/components/Chart.tsx new file mode 100644 index 0000000..07cd888 --- /dev/null +++ b/components/Chart.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { createChart, IChartApi, ISeriesApi, CandlestickData, Time } from 'lightweight-charts'; +import { DailyEntry } from '@/lib/lifestock'; + +interface ChartProps { + entries: DailyEntry[]; +} + +export default function Chart({ entries }: ChartProps) { + const chartContainerRef = useRef(null); + const chartRef = useRef(null); + const seriesRef = useRef | null>(null); + + useEffect(() => { + if (!chartContainerRef.current) return; + + // Create chart + const chart = createChart(chartContainerRef.current, { + width: chartContainerRef.current.clientWidth, + height: 400, + layout: { + background: { color: '#000000' }, + textColor: '#d1d5db', + }, + grid: { + vertLines: { color: '#1f2937' }, + horzLines: { color: '#1f2937' }, + }, + timeScale: { + borderColor: '#374151', + }, + rightPriceScale: { + borderColor: '#374151', + }, + }); + + chartRef.current = chart; + + // Create candlestick series + const candlestickSeries = chart.addCandlestickSeries({ + upColor: '#10b981', + downColor: '#ef4444', + borderUpColor: '#10b981', + borderDownColor: '#ef4444', + wickUpColor: '#10b981', + wickDownColor: '#ef4444', + }); + + seriesRef.current = candlestickSeries; + + // Handle resize + const handleResize = () => { + if (chartContainerRef.current && chartRef.current) { + chartRef.current.applyOptions({ + width: chartContainerRef.current.clientWidth, + }); + } + }; + + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + chart.remove(); + }; + }, []); + + useEffect(() => { + if (!seriesRef.current) return; + + // Convert entries to candlestick data + const data: CandlestickData