From 7195f0d1f28b2a7bf3f04b33ce65c7b69373110f Mon Sep 17 00:00:00 2001 From: dohsimpson Date: Tue, 31 Dec 2024 13:58:14 -0500 Subject: [PATCH] Added settings, enabled calendar --- Budfile | 56 ++++++++ CHANGELOG.md | 28 ++++ app/actions/data.ts | 59 ++++++--- app/settings/layout.tsx | 9 ++ app/settings/page.tsx | 63 +++++++++ components/CoinBalance.tsx | 7 +- components/CoinsManager.tsx | 143 +++++++++++---------- components/HabitList.tsx | 2 +- components/Header.tsx | 24 ++-- components/Layout.tsx | 7 +- components/MobileNavigation.tsx | 30 ----- components/{Sidebar.tsx => Navigation.tsx} | 31 ++++- components/ui/switch.tsx | 29 +++++ hooks/useSettings.ts | 23 ++++ lib/types.ts | 47 +++++++ lib/utils/formatNumber.ts | 37 ++++++ package-lock.json | 29 +++++ package.json | 6 +- 18 files changed, 493 insertions(+), 137 deletions(-) create mode 100755 Budfile create mode 100644 CHANGELOG.md create mode 100644 app/settings/layout.tsx create mode 100644 app/settings/page.tsx delete mode 100644 components/MobileNavigation.tsx rename components/{Sidebar.tsx => Navigation.tsx} (55%) create mode 100644 components/ui/switch.tsx create mode 100644 hooks/useSettings.ts create mode 100644 lib/utils/formatNumber.ts diff --git a/Budfile b/Budfile new file mode 100755 index 0000000..05f6a58 --- /dev/null +++ b/Budfile @@ -0,0 +1,56 @@ +#!/bin/bash + +bump_version() { + echo "Which version part would you like to bump? ([M]ajor/[m]inor/[p]atch)" + read -r version_part + + # Get current version + current_version=$(node -p "require('./package.json').version") + IFS='.' read -r major minor patch <<<"$current_version" + + # Calculate new version + if [[ "$version_part" =~ ^M$ ]]; then + new_version="$((major + 1)).0.0" + elif [[ "$version_part" =~ ^m$ ]]; then + new_version="$major.$((minor + 1)).0" + elif [[ "$version_part" =~ ^p$ ]]; then + new_version="$major.$minor.$((patch + 1))" + else + echo "Invalid version part. Please use M, m, or p" + return 1 + fi + + # Update package.json with new version + sed -i "s/\"version\": \"$current_version\"/\"version\": \"$new_version\"/" package.json + echo "Version bumped from $current_version to $new_version" +} + +commit() { + # Check if package.json is staged + if git diff --cached --name-only | grep -q "package.json"; then + # Get the new version from package.json + new_version=$(node -p "require('./package.json').version") + + echo "package.json has been modified. Would you like to tag this release as v$new_version? (y/n)" + read -r response + + if [[ "$response" =~ ^[Yy]$ ]]; then + git commit + git tag -a "v$new_version" -m "Release version $new_version" + echo "Created tag v$new_version" + else + git commit + fi + else + git commit + fi +} + +docker_push() { + local version=$(node -p "require('./package.json').version") + docker tag habittrove dohsimpson/habittrove:latest + docker tag habittrove "dohsimpson/habittrove:v$version" + docker push dohsimpson/habittrove:latest + docker push "dohsimpson/habittrove:v$version" + echo "Pushed Docker images with tags: latest and v$version" +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..085da4c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,28 @@ +# Changelog + +## Version 0.1.1 + +### Added + +- Settings: + - added button to show settings + - coin display settings +- Features: + - Enabled calendar in large viewport + +### Fixed + +- format big coin number + +## Version 0.1.0 + +### Added + +- Features: + - dashboard + - habits + - coins + - wishlist +- Demo +- README +- License diff --git a/app/actions/data.ts b/app/actions/data.ts index 45d7a4f..788cab4 100644 --- a/app/actions/data.ts +++ b/app/actions/data.ts @@ -2,9 +2,21 @@ import fs from 'fs/promises' import path from 'path' -import { HabitsData, CoinsData, CoinTransaction, TransactionType, WishlistItemType } from '@/lib/types' +import { + HabitsData, + CoinsData, + CoinTransaction, + TransactionType, + WishlistItemType, + WishlistData, + Settings, + DataType, + DATA_DEFAULTS +} from '@/lib/types' -type DataType = 'wishlist' | 'habits' | 'coins' +function getDefaultData(type: DataType): T { + return DATA_DEFAULTS[type]() as T; +} async function ensureDataDir() { const dataDir = path.join(process.cwd(), 'data') @@ -23,13 +35,8 @@ async function loadData(type: DataType): Promise { try { await fs.access(filePath) } catch { - // File doesn't exist, create it with initial data - const initialData = type === 'wishlist' - ? { items: [] } - : type === 'habits' - ? { habits: [] } - : { balance: 0, transactions: [] } - + // File doesn't exist, create it with default data + const initialData = getDefaultData(type) await fs.writeFile(filePath, JSON.stringify(initialData, null, 2)) return initialData as T } @@ -37,13 +44,10 @@ async function loadData(type: DataType): Promise { // File exists, read and return its contents const data = await fs.readFile(filePath, 'utf8') const jsonData = JSON.parse(data) - return type === 'wishlist' ? jsonData.items : jsonData + return jsonData } catch (error) { console.error(`Error loading ${type} data:`, error) - if (type === 'wishlist') return [] as T - if (type === 'habits') return { habits: [] } as T - if (type === 'coins') return { balance: 0, transactions: [] } as T - return {} as T + return getDefaultData(type) } } @@ -51,7 +55,7 @@ async function saveData(type: DataType, data: T): Promise { try { await ensureDataDir() const filePath = path.join(process.cwd(), 'data', `${type}.json`) - const saveData = type === 'wishlist' ? { items: data } : data + const saveData = data await fs.writeFile(filePath, JSON.stringify(saveData, null, 2)) } catch (error) { console.error(`Error saving ${type} data:`, error) @@ -60,11 +64,12 @@ async function saveData(type: DataType, data: T): Promise { // Wishlist specific functions export async function loadWishlistItems(): Promise { - return loadData('wishlist') + const data = await loadData('wishlist') + return data.items } export async function saveWishlistItems(items: WishlistItemType[]): Promise { - return saveData('wishlist', items) + return saveData('wishlist', { items }) } // Habits specific functions @@ -114,6 +119,26 @@ export async function addCoins( return newData } +export async function loadSettings(): Promise { + const defaultSettings: Settings = { + ui: { + useNumberFormatting: true, + useGrouping: true, + } + } + + try { + const data = await loadData('settings') + return { ...defaultSettings, ...data } + } catch { + return defaultSettings + } +} + +export async function saveSettings(settings: Settings): Promise { + return saveData('settings', settings) +} + export async function removeCoins( amount: number, description: string, diff --git a/app/settings/layout.tsx b/app/settings/layout.tsx new file mode 100644 index 0000000..bfc8551 --- /dev/null +++ b/app/settings/layout.tsx @@ -0,0 +1,9 @@ +import Layout from '@/components/Layout' + +export default function SettingsLayout({ + children, +}: { + children: React.ReactNode +}) { + return {children} +} diff --git a/app/settings/page.tsx b/app/settings/page.tsx new file mode 100644 index 0000000..1c4e434 --- /dev/null +++ b/app/settings/page.tsx @@ -0,0 +1,63 @@ +'use client' + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Switch } from '@/components/ui/switch' +import { Label } from '@/components/ui/label' +import { useSettings } from '@/hooks/useSettings' + +export default function SettingsPage() { + const { settings, updateSettings } = useSettings() + + if (!settings) return null + + return ( +
+

Settings

+ + + + UI Settings + + +
+
+ +
+ Format large numbers (e.g., 1K, 1M, 1B) +
+
+ + updateSettings({ + ...settings, + ui: { ...settings.ui, useNumberFormatting: checked } + }) + } + /> +
+ +
+
+ +
+ Use thousand separators (e.g., 1,000 vs 1000) +
+
+ + updateSettings({ + ...settings, + ui: { ...settings.ui, useGrouping: checked } + }) + } + /> +
+
+
+
+ ) +} diff --git a/components/CoinBalance.tsx b/components/CoinBalance.tsx index 2e66870..18576b9 100644 --- a/components/CoinBalance.tsx +++ b/components/CoinBalance.tsx @@ -1,7 +1,10 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Coins } from 'lucide-react' +import { formatNumber } from '@/lib/utils/formatNumber' +import { useSettings } from '@/hooks/useSettings' export default function CoinBalance({ coinBalance }: { coinBalance: number }) { + const { settings } = useSettings() return ( @@ -10,7 +13,9 @@ export default function CoinBalance({ coinBalance }: { coinBalance: number }) {
- {coinBalance} + + {formatNumber({ amount: coinBalance, settings })} +
diff --git a/components/CoinsManager.tsx b/components/CoinsManager.tsx index ac960ff..c2b7463 100644 --- a/components/CoinsManager.tsx +++ b/components/CoinsManager.tsx @@ -1,7 +1,9 @@ 'use client' import { useState } from 'react' +import { useSettings } from '@/hooks/useSettings' import { Button } from '@/components/ui/button' +import { formatNumber } from '@/lib/utils/formatNumber' import { History } from 'lucide-react' import EmptyState from './EmptyState' import { Input } from '@/components/ui/input' @@ -13,6 +15,7 @@ import Link from 'next/link' export default function CoinsManager() { const { balance, transactions, addAmount, removeAmount } = useCoins() + const { settings } = useSettings() const DEFAULT_AMOUNT = '0' const [amount, setAmount] = useState(DEFAULT_AMOUNT) @@ -43,7 +46,7 @@ export default function CoinsManager() { 🪙
Current Balance
-
{balance} coins
+
{formatNumber({ amount: balance, settings })} coins
@@ -108,29 +111,33 @@ export default function CoinsManager() {
Total Earned
- {transactions - .filter(t => { - if (t.type === 'HABIT_COMPLETION' && t.relatedItemId) { - return !transactions.some(undoT => - undoT.type === 'HABIT_UNDO' && - undoT.relatedItemId === t.relatedItemId - ) - } - return t.amount > 0 && t.type !== 'HABIT_UNDO' - }) - .reduce((sum, t) => sum + t.amount, 0) - } 🪙 + {formatNumber({ + amount: transactions + .filter(t => { + if (t.type === 'HABIT_COMPLETION' && t.relatedItemId) { + return !transactions.some(undoT => + undoT.type === 'HABIT_UNDO' && + undoT.relatedItemId === t.relatedItemId + ) + } + return t.amount > 0 && t.type !== 'HABIT_UNDO' + }) + .reduce((sum, t) => sum + t.amount, 0) + , settings + })} 🪙
Total Spent
- {Math.abs( - transactions - .filter(t => t.type === 'WISH_REDEMPTION' || t.type === 'MANUAL_ADJUSTMENT') - .reduce((sum, t) => sum + (t.amount < 0 ? t.amount : 0), 0) - )} 🪙 + {formatNumber({ + amount: Math.abs( + transactions + .filter(t => t.type === 'WISH_REDEMPTION' || t.type === 'MANUAL_ADJUSTMENT') + .reduce((sum, t) => sum + (t.amount < 0 ? t.amount : 0), 0) + ), settings + })} 🪙
@@ -165,59 +172,59 @@ export default function CoinsManager() { /> ) : ( transactions.map((transaction) => { - const getBadgeStyles = () => { - switch (transaction.type) { - case 'HABIT_COMPLETION': - return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100' - case 'HABIT_UNDO': - return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100' - case 'WISH_REDEMPTION': - return 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-100' - case 'MANUAL_ADJUSTMENT': - return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100' - default: - return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-100' + const getBadgeStyles = () => { + switch (transaction.type) { + case 'HABIT_COMPLETION': + return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100' + case 'HABIT_UNDO': + return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100' + case 'WISH_REDEMPTION': + return 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-100' + case 'MANUAL_ADJUSTMENT': + return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100' + default: + return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-100' + } } - } - return ( -
-
-
- {transaction.relatedItemId ? ( - - {transaction.description} - - ) : ( -

{transaction.description}

- )} - - {transaction.type.split('_').join(' ')} - -
-

- {format(new Date(transaction.timestamp), 'PPpp')} -

-
- = 0 - ? 'text-green-600 dark:text-green-400' - : 'text-red-600 dark:text-red-400' - }`} + return ( +
- {transaction.amount >= 0 ? '+' : ''}{transaction.amount} - -
- ) +
+
+ {transaction.relatedItemId ? ( + + {transaction.description} + + ) : ( +

{transaction.description}

+ )} + + {transaction.type.split('_').join(' ')} + +
+

+ {format(new Date(transaction.timestamp), 'PPpp')} +

+
+ = 0 + ? 'text-green-600 dark:text-green-400' + : 'text-red-600 dark:text-red-400' + }`} + > + {transaction.amount >= 0 ? '+' : ''}{transaction.amount} + +
+ ) }) )} diff --git a/components/HabitList.tsx b/components/HabitList.tsx index e26e22d..43cd463 100644 --- a/components/HabitList.tsx +++ b/components/HabitList.tsx @@ -61,7 +61,7 @@ export default function HabitList() { }} onSave={async (habit) => { if (editingHabit) { - await editHabit(editingHabit) + await editHabit({ ...habit, id: editingHabit.id }) } else { await addHabit(habit) } diff --git a/components/Header.tsx b/components/Header.tsx index d3587f6..6f06e7c 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -1,6 +1,7 @@ import { Bell, Settings } from 'lucide-react' import { Button } from '@/components/ui/button' import { Logo } from '@/components/Logo' +import Link from 'next/link' interface HeaderProps { className?: string @@ -12,17 +13,22 @@ export default function Header({ className }: HeaderProps) {
- {/*
*/} - {/* */} - {/* */} - {/*
*/} +
+ {/* */} + + + +
- + ) } diff --git a/components/Layout.tsx b/components/Layout.tsx index 3e9f987..08e6f20 100644 --- a/components/Layout.tsx +++ b/components/Layout.tsx @@ -1,18 +1,17 @@ import Header from './Header' -import Sidebar from './Sidebar' -import MobileNavigation from './MobileNavigation' +import Navigation from './Navigation' export default function Layout({ children }: { children: React.ReactNode }) { return (
- +
{children}
- +
diff --git a/components/MobileNavigation.tsx b/components/MobileNavigation.tsx deleted file mode 100644 index 2bf25e3..0000000 --- a/components/MobileNavigation.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import Link from 'next/link' -import { Home, Calendar, List, Gift, Coins } from 'lucide-react' - -const navItems = [ - { icon: Home, label: 'Dashboard', href: '/' }, - { icon: List, label: 'Habits', href: '/habits' }, - { icon: Calendar, label: 'Calendar', href: '/calendar' }, - { icon: Gift, label: 'Wishlist', href: '/wishlist' }, - { icon: Coins, label: 'Coins', href: '/coins' }, -] - -export default function MobileNavigation() { - return ( - - ) -} - diff --git a/components/Sidebar.tsx b/components/Navigation.tsx similarity index 55% rename from components/Sidebar.tsx rename to components/Navigation.tsx index 5e22ec9..09189f4 100644 --- a/components/Sidebar.tsx +++ b/components/Navigation.tsx @@ -1,15 +1,39 @@ import Link from 'next/link' -import { Home, Calendar, List, Gift, Coins } from 'lucide-react' +import { Home, Calendar, List, Gift, Coins, Settings } from 'lucide-react' const navItems = [ { icon: Home, label: 'Dashboard', href: '/' }, { icon: List, label: 'Habits', href: '/habits' }, - // { icon: Calendar, label: 'Calendar', href: '/calendar' }, + { icon: Calendar, label: 'Calendar', href: '/calendar' }, { icon: Gift, label: 'Wishlist', href: '/wishlist' }, { icon: Coins, label: 'Coins', href: '/coins' }, ] -export default function Sidebar() { +interface NavigationProps { + className?: string + isMobile?: boolean +} + +export default function Navigation({ className, isMobile = false }: NavigationProps) { + if (isMobile) { + return ( + + ) + } + return (
@@ -33,4 +57,3 @@ export default function Sidebar() {
) } - diff --git a/components/ui/switch.tsx b/components/ui/switch.tsx new file mode 100644 index 0000000..5f4117f --- /dev/null +++ b/components/ui/switch.tsx @@ -0,0 +1,29 @@ +"use client" + +import * as React from "react" +import * as SwitchPrimitives from "@radix-ui/react-switch" + +import { cn } from "@/lib/utils" + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +Switch.displayName = SwitchPrimitives.Root.displayName + +export { Switch } diff --git a/hooks/useSettings.ts b/hooks/useSettings.ts new file mode 100644 index 0000000..8c7d420 --- /dev/null +++ b/hooks/useSettings.ts @@ -0,0 +1,23 @@ +'use client' + +import { useEffect, useState } from 'react' +import { getDefaultSettings, Settings } from '@/lib/types' +import { loadSettings, saveSettings } from '@/app/actions/data' + +export function useSettings() { + const [settings, setSettings] = useState(getDefaultSettings()) // TODO: do we need to initialize the settings here? + + useEffect(() => { + loadSettings().then(setSettings) + }, []) + + const updateSettings = async (newSettings: Settings) => { + await saveSettings(newSettings) + setSettings(newSettings) + } + + return { + settings, + updateSettings, + } +} diff --git a/lib/types.ts b/lib/types.ts index 98ef9c8..9b33cca 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -33,3 +33,50 @@ export interface CoinsData { balance: number; transactions: CoinTransaction[]; } + +// Default value functions +// Data container types +export interface WishlistData { + items: WishlistItemType[]; +} + +// Default value functions +export const getDefaultHabitsData = (): HabitsData => ({ + habits: [] +}); + +export const getDefaultCoinsData = (): CoinsData => ({ + balance: 0, + transactions: [] +}); + +export const getDefaultWishlistData = (): WishlistData => ({ + items: [] +}); + +export const getDefaultSettings = (): Settings => ({ + ui: { + useNumberFormatting: true, + useGrouping: true, + } +}); + +// Map of data types to their default values +export const DATA_DEFAULTS = { + wishlist: getDefaultWishlistData, + habits: getDefaultHabitsData, + coins: getDefaultCoinsData, + settings: getDefaultSettings, +} as const; + +// Type for all possible data types +export type DataType = keyof typeof DATA_DEFAULTS; + +export interface UISettings { + useNumberFormatting: boolean; + useGrouping: boolean; +} + +export interface Settings { + ui: UISettings; +} diff --git a/lib/utils/formatNumber.ts b/lib/utils/formatNumber.ts new file mode 100644 index 0000000..ef74b2c --- /dev/null +++ b/lib/utils/formatNumber.ts @@ -0,0 +1,37 @@ +import { Settings } from "../types"; + +function formatWithLocale(amount: number, useGrouping: boolean, maximumFractionDigits?: number): string { + return amount.toLocaleString(undefined, { + maximumFractionDigits, + useGrouping, + }); +} + +export function formatNumber({ amount, settings }: { amount: number, settings: Settings }): string { + const useFormatting = settings?.ui.useNumberFormatting ?? true; + const useGrouping = settings?.ui.useGrouping ?? true; + + if (!useFormatting) { + return useGrouping ? formatWithLocale(amount, true) : amount.toString(); + } + + const absNum = Math.abs(amount); + + if (absNum >= 1e12) { + return amount.toExponential(2); + } + + if (absNum >= 1e9) { + return formatWithLocale(amount / 1e9, useGrouping, 1) + 'B'; + } + + if (absNum >= 1e6) { + return formatWithLocale(amount / 1e6, useGrouping, 1) + 'M'; + } + + if (absNum >= 1e3) { + return formatWithLocale(amount / 1e3, useGrouping, 1) + 'K'; + } + + return formatWithLocale(amount, useGrouping); +} diff --git a/package-lock.json b/package-lock.json index d8f60c5..30bc54a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@radix-ui/react-progress": "^1.1.1", "@radix-ui/react-select": "^2.1.4", "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-toast": "^1.2.4", "@types/canvas-confetti": "^1.9.0", "@uiw/react-heat-map": "^2.3.2", @@ -1299,6 +1300,34 @@ } } }, + "node_modules/@radix-ui/react-switch": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.2.tgz", + "integrity": "sha512-zGukiWHjEdBCRyXvKR6iXAQG6qXm2esuAD6kDOi9Cn+1X6ev3ASo4+CsYaD6Fov9r/AQFekqnD/7+V0Cs6/98g==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-toast": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.4.tgz", diff --git a/package.json b/package.json index 8b349c8..b60b1fb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "habittrove", - "version": "0.1.0", + "version": "0.1.1", "private": true, "scripts": { "dev": "next dev --turbopack", @@ -8,8 +8,7 @@ "start": "next start", "lint": "next lint", "docker-build": "docker build -t habittrove .", - "docker-run": "docker run -p 3000:3000 -v $(pwd)/data:/app/data habittrove", - "docker-push": "docker tag habittrove dohsimpson/habittrove && docker push dohsimpson/habittrove" + "docker-run": "docker run -p 3000:3000 -v $(pwd)/data:/app/data habittrove" }, "dependencies": { "@next/font": "^14.2.15", @@ -18,6 +17,7 @@ "@radix-ui/react-progress": "^1.1.1", "@radix-ui/react-select": "^2.1.4", "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-toast": "^1.2.4", "@types/canvas-confetti": "^1.9.0", "@uiw/react-heat-map": "^2.3.2",