added timezone settings

This commit is contained in:
dohsimpson
2025-01-01 22:38:45 -05:00
parent 3ac67ca413
commit 11ea0ff89e
17 changed files with 251 additions and 15 deletions

View File

@@ -1,5 +1,16 @@
# Changelog
## Version 0.1.5
### Added
- docker-compose.yaml
- timezone settings
### Fixed
- completing habits now respect timezone settings
## Version 0.1.4
### Changed

View File

@@ -124,6 +124,9 @@ export async function loadSettings(): Promise<Settings> {
ui: {
useNumberFormatting: true,
useGrouping: true,
},
system: {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
}
}

View File

@@ -4,6 +4,7 @@ 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'
import { DynamicTimeNoSSR } from '@/components/DynamicTimeNoSSR'
export default function SettingsPage() {
const { settings, updateSettings } = useSettings()
@@ -13,7 +14,6 @@ export default function SettingsPage() {
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Settings</h1>
<Card className="mb-6">
<CardHeader>
<CardTitle>UI Settings</CardTitle>
@@ -58,6 +58,42 @@ export default function SettingsPage() {
</div>
</CardContent>
</Card>
<Card className="mb-6">
<CardHeader>
<CardTitle>System Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="timezone">Timezone</Label>
<div className="text-sm text-muted-foreground">
Select your timezone for accurate date tracking
</div>
</div>
<div className="flex flex-col items-end gap-2">
<select
id="timezone"
value={settings.system.timezone}
onChange={(e) =>
updateSettings({
...settings,
system: { ...settings.system, timezone: e.target.value }
})
}
className="w-[200px] rounded-md border border-input bg-background px-3 py-2"
>
{Intl.supportedValuesOf('timeZone').map((tz) => (
<option key={tz} value={tz}>
{tz}
</option>
))}
</select>
<DynamicTimeNoSSR timezone={settings.system.timezone} />
</div>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -2,6 +2,7 @@
import { useState } from 'react'
import { useSettings } from '@/hooks/useSettings'
import { getDateInTimezone } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { formatNumber } from '@/lib/utils/formatNumber'
import { History } from 'lucide-react'
@@ -10,7 +11,6 @@ import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { toast } from '@/hooks/use-toast'
import { useCoins } from '@/hooks/useCoins'
import { format } from 'date-fns'
import Link from 'next/link'
export default function CoinsManager() {
@@ -145,7 +145,8 @@ export default function CoinsManager() {
<div className="text-sm text-blue-800 dark:text-blue-100 mb-1">Today's Transactions</div>
<div className="text-2xl font-bold text-blue-900 dark:text-blue-50">
{transactions.filter(t =>
new Date(t.timestamp).toDateString() === new Date().toDateString()
getDateInTimezone(t.timestamp, settings.system.timezone).toDateString() ===
getDateInTimezone(new Date(), settings.system.timezone).toDateString()
).length} 📊
</div>
</div>
@@ -212,7 +213,7 @@ export default function CoinsManager() {
</span>
</div>
<p className="text-sm text-gray-500">
{format(new Date(transaction.timestamp), 'PPpp')}
{getDateInTimezone(transaction.timestamp, settings.system.timezone).toLocaleString()}
</p>
</div>
<span
@@ -231,6 +232,6 @@ export default function CoinsManager() {
</CardContent>
</Card>
</div>
</div>
</div >
)
}

View File

@@ -1,6 +1,8 @@
import { Circle, Coins, ArrowRight, CircleCheck, ChevronDown, ChevronUp } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import { useSettings } from '@/hooks/useSettings'
import { getTodayInTimezone } from '@/lib/utils'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
@@ -22,7 +24,8 @@ export default function DailyOverview({
onComplete,
onUndo
}: UpcomingItemsProps) {
const today = new Date().toISOString().split('T')[0]
const { settings } = useSettings()
const today = getTodayInTimezone(settings.system.timezone)
const todayCompletions = habits.filter(habit =>
habit.completions.includes(today)
)

View File

@@ -0,0 +1,26 @@
'use client'
import { useEffect, useState } from 'react'
import moment from 'moment-timezone'
interface DynamicTimeProps {
timezone: string
}
export function DynamicTime({ timezone }: DynamicTimeProps) {
const [time, setTime] = useState(moment())
useEffect(() => {
const timer = setInterval(() => {
setTime(moment())
}, 1000)
return () => clearInterval(timer)
}, [])
return (
<div className="text-sm text-muted-foreground">
{time.tz(timezone).format('dddd, MMMM D, YYYY h:mm:ss A')}
</div>
)
}

View File

@@ -0,0 +1,8 @@
import dynamic from 'next/dynamic'
import { DynamicTime } from './DynamicTime'
const DynamicTimeNoSSR = dynamic(() => Promise.resolve(DynamicTime), {
ssr: false
})
export { DynamicTimeNoSSR }

View File

@@ -1,4 +1,6 @@
import { Habit } from '@/lib/types'
import { useSettings } from '@/hooks/useSettings'
import { getTodayInTimezone } from '@/lib/utils'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Coins, Edit, Trash2, Check, Undo2 } from 'lucide-react'
@@ -13,7 +15,8 @@ interface HabitItemProps {
}
export default function HabitItem({ habit, onEdit, onDelete, onComplete, onUndo }: HabitItemProps) {
const today = new Date().toISOString().split('T')[0]
const { settings } = useSettings()
const today = getTodayInTimezone(settings.system.timezone)
const isCompletedToday = habit.completions?.includes(today)
const [isHighlighted, setIsHighlighted] = useState(false)

View File

@@ -1,6 +1,8 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { BarChart } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useSettings } from '@/hooks/useSettings'
import { getTodayInTimezone } from '@/lib/utils'
import { loadHabitsData } from '@/app/actions/data'
import { Habit } from '@/lib/types'
@@ -15,7 +17,8 @@ export default function HabitOverview() {
fetchHabits()
}, [])
const today = new Date().toISOString().split('T')[0]
const { settings } = useSettings()
const today = getTodayInTimezone(settings.system.timezone)
const completedToday = habits.filter(habit =>
habit.completions.includes(today)

View File

@@ -2,6 +2,8 @@
import { Habit } from '@/lib/types'
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { useSettings } from '@/hooks/useSettings'
import { getDateInTimezone } from '@/lib/utils'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
interface HabitStreakProps {
@@ -11,7 +13,8 @@ interface HabitStreakProps {
export default function HabitStreak({ habits }: HabitStreakProps) {
// Get the last 30 days of data
const dates = Array.from({ length: 30 }, (_, i) => {
const d = new Date()
const { settings } = useSettings()
const d = getDateInTimezone(new Date(), settings.system.timezone)
d.setDate(d.getDate() - i)
return d.toISOString().split('T')[0]
}).reverse()

7
docker-compose.yaml Normal file
View File

@@ -0,0 +1,7 @@
services:
habittrove:
ports:
- "3000:3000"
volumes:
- "./data:/app/data" # Use a relative path instead of $(pwd)
image: dohsimpson/habittrove

View File

@@ -1,12 +1,15 @@
import { useState, useEffect } from 'react'
import { useSettings } from '@/hooks/useSettings'
import { loadHabitsData, saveHabitsData, addCoins, removeCoins } from '@/app/actions/data'
import { toast } from '@/hooks/use-toast'
import { ToastAction } from '@/components/ui/toast'
import { Undo2 } from 'lucide-react'
import { Habit } from '@/lib/types'
import { getTodayInTimezone } from '@/lib/utils'
export function useHabits() {
const [habits, setHabits] = useState<Habit[]>([])
const { settings } = useSettings()
useEffect(() => {
fetchHabits()
@@ -39,7 +42,7 @@ export function useHabits() {
}
const completeHabit = async (habit: Habit) => {
const today = new Date().toISOString().split('T')[0]
const today = getTodayInTimezone(settings.system.timezone)
if (!habit.completions.includes(today)) {
const updatedHabit = {
...habit,

View File

@@ -58,6 +58,9 @@ export const getDefaultSettings = (): Settings => ({
ui: {
useNumberFormatting: true,
useGrouping: true,
},
system: {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
}
});
@@ -77,6 +80,11 @@ export interface UISettings {
useGrouping: boolean;
}
export interface SystemSettings {
timezone: string;
}
export interface Settings {
ui: UISettings;
system: SystemSettings;
}

50
lib/utils.test.ts Normal file
View File

@@ -0,0 +1,50 @@
import { expect, test, describe, beforeAll, afterAll } from "bun:test";
import { cn, getDateInTimezone, getTodayInTimezone } from './utils'
describe('cn utility', () => {
test('should merge class names correctly', () => {
expect(cn('foo', 'bar')).toBe('foo bar')
expect(cn('foo', { bar: true })).toBe('foo bar')
expect(cn('foo', { bar: false })).toBe('foo')
expect(cn('foo', ['bar', 'baz'])).toBe('foo bar baz')
})
})
describe('timezone utilities', () => {
describe('getDateInTimezone', () => {
test('should convert date to specified timezone', () => {
const date = new Date('2024-01-01T00:00:00Z')
// Test with specific timezones
const nyDate = getDateInTimezone(date, 'America/New_York')
expect(nyDate.toISOString()).toBe('2023-12-31T19:00:00.000Z') // NY is UTC-5
const tokyoDate = getDateInTimezone(date, 'Asia/Tokyo')
expect(tokyoDate.toISOString()).toBe('2024-01-01T09:00:00.000Z') // Tokyo is UTC+9
})
test('should handle string dates', () => {
const dateStr = '2024-01-01T00:00:00Z'
const nyDate = getDateInTimezone(dateStr, 'America/New_York')
expect(nyDate.toISOString()).toBe('2023-12-31T19:00:00.000Z')
})
})
describe('getTodayInTimezone', () => {
let originalDate: Date;
beforeAll(() => {
originalDate = new Date();
globalThis.Date.now = () => new Date('2024-01-01T00:00:00Z').getTime();
})
afterAll(() => {
globalThis.Date.now = () => originalDate.getTime();
})
test('should return today in YYYY-MM-DD format for timezone', () => {
expect(getTodayInTimezone('America/New_York')).toBe('2023-12-31')
expect(getTodayInTimezone('Asia/Tokyo')).toBe('2024-01-01')
})
})
})

View File

@@ -1,6 +1,16 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import moment from "moment-timezone"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function getDateInTimezone(date: Date | string, timezone: string): Date {
const m = moment.tz(date, timezone);
return new Date(m.format('YYYY-MM-DDTHH:mm:ss.SSS[Z]'));
}
export function getTodayInTimezone(timezone: string): string {
return moment.tz(timezone).format('YYYY-MM-DD');
}

68
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "habittrove",
"version": "0.1.3",
"version": "0.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "habittrove",
"version": "0.1.3",
"version": "0.1.4",
"dependencies": {
"@next/font": "^14.2.15",
"@radix-ui/react-dialog": "^1.1.4",
@@ -16,6 +16,7 @@
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4",
"@types/bun": "^1.1.14",
"@types/canvas-confetti": "^1.9.0",
"@uiw/react-heat-map": "^2.3.2",
"canvas-confetti": "^1.9.3",
@@ -24,6 +25,8 @@
"date-fns": "^3.6.0",
"js-confetti": "^0.12.0",
"lucide-react": "^0.469.0",
"moment": "^2.30.1",
"moment-timezone": "^0.5.46",
"next": "15.1.3",
"react": "^19.0.0",
"react-confetti": "^6.2.2",
@@ -1566,6 +1569,14 @@
"node": ">=4"
}
},
"node_modules/@types/bun": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.1.14.tgz",
"integrity": "sha512-opVYiFGtO2af0dnWBdZWlioLBoxSdDO5qokaazLhq8XQtGZbY4pY3/JxY8Zdf/hEwGubbp7ErZXoN1+h2yesxA==",
"dependencies": {
"bun-types": "1.1.37"
}
},
"node_modules/@types/canvas-confetti": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz",
@@ -1705,7 +1716,6 @@
"version": "20.17.10",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.10.tgz",
"integrity": "sha512-/jrvh5h6NXhEauFFexRin69nA0uHJ5gwk4iDivp/DeoEua3uwCUto6PC86IpRITBOs4+6i2I56K5x5b6WYGXHA==",
"dev": true,
"dependencies": {
"undici-types": "~6.19.2"
}
@@ -1732,6 +1742,14 @@
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="
},
"node_modules/@types/ws": {
"version": "8.5.13",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz",
"integrity": "sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA==",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.18.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.18.2.tgz",
@@ -2593,6 +2611,28 @@
"dev": true,
"peer": true
},
"node_modules/bun-types": {
"version": "1.1.37",
"resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.1.37.tgz",
"integrity": "sha512-C65lv6eBr3LPJWFZ2gswyrGZ82ljnH8flVE03xeXxKhi2ZGtFiO4isRKTKnitbSqtRAcaqYSR6djt1whI66AbA==",
"dependencies": {
"@types/node": "~20.12.8",
"@types/ws": "~8.5.10"
}
},
"node_modules/bun-types/node_modules/@types/node": {
"version": "20.12.14",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.14.tgz",
"integrity": "sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg==",
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/bun-types/node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
@@ -5913,6 +5953,25 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"engines": {
"node": "*"
}
},
"node_modules/moment-timezone": {
"version": "0.5.46",
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.46.tgz",
"integrity": "sha512-ZXm9b36esbe7OmdABqIWJuBBiLLwAjrN7CE+7sYdCCx82Nabt1wHDj8TVseS59QIlfFPbOoiBPm6ca9BioG4hw==",
"dependencies": {
"moment": "^2.29.4"
},
"engines": {
"node": "*"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -7997,8 +8056,7 @@
"node_modules/undici-types": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
"dev": true
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="
},
"node_modules/unified": {
"version": "11.0.5",

View File

@@ -19,6 +19,7 @@
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4",
"@types/bun": "^1.1.14",
"@types/canvas-confetti": "^1.9.0",
"@uiw/react-heat-map": "^2.3.2",
"canvas-confetti": "^1.9.3",
@@ -27,6 +28,8 @@
"date-fns": "^3.6.0",
"js-confetti": "^0.12.0",
"lucide-react": "^0.469.0",
"moment": "^2.30.1",
"moment-timezone": "^0.5.46",
"next": "15.1.3",
"react": "^19.0.0",
"react-confetti": "^6.2.2",