refactor: replace moment library with luxon

This commit is contained in:
dohsimpson
2025-01-02 18:26:52 -05:00
parent 01c75e5412
commit e2ae2bafa7
12 changed files with 193 additions and 92 deletions

View File

@@ -93,3 +93,11 @@ docker_push() {
echo "Pushed Docker images with tags: v$version" echo "Pushed Docker images with tags: v$version"
fi fi
} }
run() {
npm run dev
}
build() {
npm run build
}

View File

@@ -13,6 +13,7 @@ import {
DataType, DataType,
DATA_DEFAULTS DATA_DEFAULTS
} from '@/lib/types' } from '@/lib/types'
import { d2t, getNow, getNowInMilliseconds } from '@/lib/utils';
function getDefaultData<T>(type: DataType): T { function getDefaultData<T>(type: DataType): T {
return DATA_DEFAULTS[type]() as T; return DATA_DEFAULTS[type]() as T;
@@ -106,7 +107,7 @@ export async function addCoins(
amount, amount,
type, type,
description, description,
timestamp: new Date().toISOString(), timestamp: d2t({ dateTime: getNow({}) }),
...(relatedItemId && { relatedItemId }) ...(relatedItemId && { relatedItemId })
} }
@@ -154,7 +155,7 @@ export async function removeCoins(
amount: -amount, amount: -amount,
type, type,
description, description,
timestamp: new Date().toISOString(), timestamp: d2t({ dateTime: getNow({}) }),
...(relatedItemId && { relatedItemId }) ...(relatedItemId && { relatedItemId })
} }

View File

@@ -2,7 +2,7 @@
import { useState } from 'react' import { useState } from 'react'
import { useSettings } from '@/hooks/useSettings' import { useSettings } from '@/hooks/useSettings'
import { getDateInTimezone } from '@/lib/utils' import { t2d, d2s, getNow, isSameDate } from '@/lib/utils'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { formatNumber } from '@/lib/utils/formatNumber' import { formatNumber } from '@/lib/utils/formatNumber'
import { History } from 'lucide-react' import { History } from 'lucide-react'
@@ -145,8 +145,7 @@ export default function CoinsManager() {
<div className="text-sm text-blue-800 dark:text-blue-100 mb-1">Today's Transactions</div> <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"> <div className="text-2xl font-bold text-blue-900 dark:text-blue-50">
{transactions.filter(t => {transactions.filter(t =>
getDateInTimezone(t.timestamp, settings.system.timezone).toDateString() === isSameDate(getNow({}), t2d({ timestamp: t.timestamp }))
getDateInTimezone(new Date(), settings.system.timezone).toDateString()
).length} 📊 ).length} 📊
</div> </div>
</div> </div>
@@ -213,7 +212,7 @@ export default function CoinsManager() {
</span> </span>
</div> </div>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
{getDateInTimezone(transaction.timestamp, settings.system.timezone).toLocaleString()} {d2s({ dateTime: t2d({ timestamp: transaction.timestamp, timezone: settings.system.timezone }) })}
</p> </p>
</div> </div>
<span <span

View File

@@ -1,18 +1,19 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import moment from 'moment-timezone' import { DateTime } from 'luxon'
import { d2s } from '@/lib/utils'
interface DynamicTimeProps { interface DynamicTimeProps {
timezone: string timezone: string
} }
export function DynamicTime({ timezone }: DynamicTimeProps) { export function DynamicTime({ timezone }: DynamicTimeProps) {
const [time, setTime] = useState(moment()) const [time, setTime] = useState(DateTime.now().setZone(timezone))
useEffect(() => { useEffect(() => {
const timer = setInterval(() => { const timer = setInterval(() => {
setTime(moment()) setTime((prevTime) => prevTime.plus({ seconds: 1 }))
}, 1000) }, 1000)
return () => clearInterval(timer) return () => clearInterval(timer)
@@ -20,7 +21,7 @@ export function DynamicTime({ timezone }: DynamicTimeProps) {
return ( return (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{time.tz(timezone).format('dddd, MMMM D, YYYY h:mm:ss A')} {d2s({ dateTime: time })}
</div> </div>
) )
} }

View File

@@ -7,15 +7,25 @@ import { Badge } from '@/components/ui/badge'
import { loadHabitsData } from '@/app/actions/data' import { loadHabitsData } from '@/app/actions/data'
import { Habit } from '@/lib/types' import { Habit } from '@/lib/types'
import { d2s, getNow } from '@/lib/utils'
import { useSettings } from '@/hooks/useSettings'
import { DateTime } from 'luxon'
export default function HabitCalendar() { export default function HabitCalendar() {
const [selectedDate, setSelectedDate] = useState<Date | undefined>(new Date()) const { settings } = useSettings()
const [selectedDate, setSelectedDate] = useState<DateTime>(getNow({ timezone: settings.system.timezone }))
const [habits, setHabits] = useState<Habit[]>([]) const [habits, setHabits] = useState<Habit[]>([])
useEffect(() => { useEffect(() => {
fetchHabitsData() fetchHabitsData()
}, []) }, [])
// Update selectedDate when timezone changes
useEffect(() => {
const now = getNow({ timezone: settings.system.timezone })
setSelectedDate(now)
}, [settings])
const fetchHabitsData = async () => { const fetchHabitsData = async () => {
const data = await loadHabitsData() const data = await loadHabitsData()
setHabits(data.habits) setHabits(data.habits)
@@ -39,8 +49,8 @@ export default function HabitCalendar() {
<CardContent> <CardContent>
<Calendar <Calendar
mode="single" mode="single"
selected={selectedDate} selected={selectedDate.toJSDate()}
onSelect={setSelectedDate} onSelect={(e) => e && setSelectedDate(DateTime.fromJSDate(e))}
className="rounded-md border" className="rounded-md border"
modifiers={{ modifiers={{
completed: (date) => getHabitsForDate(date).length > 0, completed: (date) => getHabitsForDate(date).length > 0,
@@ -55,7 +65,7 @@ export default function HabitCalendar() {
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>
{selectedDate ? ( {selectedDate ? (
<>Habits for {selectedDate.toLocaleDateString()}</> <>Habits for {d2s({ dateTime: selectedDate })}</>
) : ( ) : (
'Select a date' 'Select a date'
)} )}
@@ -65,7 +75,7 @@ export default function HabitCalendar() {
{selectedDate && ( {selectedDate && (
<ul className="space-y-2"> <ul className="space-y-2">
{habits.map((habit) => { {habits.map((habit) => {
const isCompleted = getHabitsForDate(selectedDate).some(h => h.id === habit.id) const isCompleted = getHabitsForDate(selectedDate.toJSDate()).some(h => h.id === habit.id)
return ( return (
<li key={habit.id} className="flex items-center justify-between"> <li key={habit.id} className="flex items-center justify-between">
<span>{habit.name}</span> <span>{habit.name}</span>

View File

@@ -2,7 +2,7 @@
import HeatMap from '@uiw/react-heat-map' import HeatMap from '@uiw/react-heat-map'
import { Habit } from '@/lib/types' import { Habit } from '@/lib/types'
import { getDateInTimezone } from '@/lib/utils' import { getNow } from '@/lib/utils'
import { useSettings } from '@/hooks/useSettings' import { useSettings } from '@/hooks/useSettings'
interface HabitHeatmapProps { interface HabitHeatmapProps {
@@ -29,9 +29,8 @@ export default function HabitHeatmap({ habits }: HabitHeatmapProps) {
const { settings } = useSettings() const { settings } = useSettings()
// Get start date (30 days ago) // Get start date (30 days ago)
const now = getDateInTimezone(new Date(), settings.system.timezone) const now = getNow({ timezone: settings.system.timezone })
const startDate = now const startDate = now.minus({ days: 30 }).toJSDate()
startDate.setDate(now.getDate() - 30)
return ( return (
<div className="p-4 bg-white rounded-lg shadow"> <div className="p-4 bg-white rounded-lg shadow">

View File

@@ -3,7 +3,7 @@
import { Habit } from '@/lib/types' import { Habit } from '@/lib/types'
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { useSettings } from '@/hooks/useSettings' import { useSettings } from '@/hooks/useSettings'
import { getDateInTimezone } from '@/lib/utils' import { d2s, getNow } from '@/lib/utils'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts' import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
interface HabitStreakProps { interface HabitStreakProps {
@@ -12,20 +12,21 @@ interface HabitStreakProps {
export default function HabitStreak({ habits }: HabitStreakProps) { export default function HabitStreak({ habits }: HabitStreakProps) {
const { settings } = useSettings() const { settings } = useSettings()
// Get the last 30 days of data // Get the last 7 days of data
const dates = Array.from({ length: 30 }, (_, i) => { const dates = Array.from({ length: 7 }, (_, i) => {
const d = getDateInTimezone(new Date(), settings.system.timezone) const d = getNow({ timezone: settings.system.timezone });
d.setDate(d.getDate() - i) return d2s({ dateTime: d.minus({ days: i }), format: 'yyyy-MM-dd' });
return d.toISOString().split('T')[0]
}).reverse() }).reverse()
// Count completed habits per day const completions = dates.map(date => {
const completions = dates.map(date => ({ const completedCount = habits.reduce((count, habit) => {
date: new Date(date).toLocaleDateString(), return count + (habit.completions.includes(date) ? 1 : 0);
completed: habits.filter(habit => }, 0);
habit.completions.includes(date) return {
).length date,
})) completed: completedCount
};
});
return ( return (
<Card> <Card>

View File

@@ -5,7 +5,7 @@ import { toast } from '@/hooks/use-toast'
import { ToastAction } from '@/components/ui/toast' import { ToastAction } from '@/components/ui/toast'
import { Undo2 } from 'lucide-react' import { Undo2 } from 'lucide-react'
import { Habit } from '@/lib/types' import { Habit } from '@/lib/types'
import { getDateInTimezone, getTodayInTimezone } from '@/lib/utils' import { getNowInMilliseconds, getTodayInTimezone } from '@/lib/utils'
export function useHabits() { export function useHabits() {
const [habits, setHabits] = useState<Habit[]>([]) const [habits, setHabits] = useState<Habit[]>([])
@@ -21,7 +21,7 @@ export function useHabits() {
} }
const addHabit = async (habit: Omit<Habit, 'id'>) => { const addHabit = async (habit: Omit<Habit, 'id'>) => {
const newHabit = { ...habit, id: getDateInTimezone(new Date(), settings.system.timezone).getTime().toString() } const newHabit = { ...habit, id: getNowInMilliseconds() }
const newHabits = [...habits, newHabit] const newHabits = [...habits, newHabit]
setHabits(newHabits) setHabits(newHabits)
await saveHabitsData({ habits: newHabits }) await saveHabitsData({ habits: newHabits })

View File

@@ -1,5 +1,6 @@
import { expect, test, describe, beforeAll, afterAll } from "bun:test"; import { expect, test, describe, beforeAll, afterAll } from "bun:test";
import { cn, getDateInTimezone, getTodayInTimezone } from './utils' import { cn, getTodayInTimezone, getNow, getNowInMilliseconds, t2d, d2t, d2s, d2sDate, d2n, isSameDate } from './utils'
import { DateTime } from "luxon";
describe('cn utility', () => { describe('cn utility', () => {
test('should merge class names correctly', () => { test('should merge class names correctly', () => {
@@ -10,41 +11,86 @@ describe('cn utility', () => {
}) })
}) })
describe('timezone utilities', () => { describe('datetime utilities', () => {
describe('getDateInTimezone', () => { let fixedNow: DateTime;
test('should convert date to specified timezone', () => {
const date = new Date('2024-01-01T00:00:00Z')
// Test with specific timezones beforeAll(() => {
const nyDate = getDateInTimezone(date, 'America/New_York') // Fix the current time to 2024-01-01T00:00:00Z
expect(nyDate.toISOString()).toBe('2023-12-31T19:00:00.000Z') // NY is UTC-5 fixedNow = DateTime.fromISO('2024-01-01T00:00:00Z');
DateTime.now = () => fixedNow;
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', () => { 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', () => { test('should return today in YYYY-MM-DD format for timezone', () => {
expect(getTodayInTimezone('America/New_York')).toBe('2023-12-31') expect(getTodayInTimezone('America/New_York')).toBe('2023-12-31')
expect(getTodayInTimezone('Asia/Tokyo')).toBe('2024-01-01') expect(getTodayInTimezone('Asia/Tokyo')).toBe('2024-01-01')
}) })
}) })
describe('getNow', () => {
test('should return current datetime in specified timezone', () => {
const nyNow = getNow({ timezone: 'America/New_York' });
expect(nyNow.zoneName).toBe('America/New_York')
expect(nyNow.year).toBe(2023)
expect(nyNow.month).toBe(12)
expect(nyNow.day).toBe(31)
})
test('should default to UTC', () => {
const utcNow = getNow({});
expect(utcNow.zoneName).toBe('UTC')
})
})
describe('getNowInMilliseconds', () => {
test('should return current time in milliseconds', () => {
expect(getNowInMilliseconds()).toBe('1704067200000')
})
})
describe('timestamp conversion utilities', () => {
const testTimestamp = '2024-01-01T00:00:00.000Z';
const testDateTime = DateTime.fromISO(testTimestamp);
test('t2d should convert ISO timestamp to DateTime', () => {
const result = t2d({ timestamp: testTimestamp });
// Normalize both timestamps to handle different UTC offset formats (Z vs +00:00)
expect(DateTime.fromISO(result.toISO()!).toMillis())
.toBe(DateTime.fromISO(testTimestamp).toMillis())
})
test('d2t should convert DateTime to ISO timestamp', () => {
const result = d2t({ dateTime: testDateTime });
expect(result).toBe(testTimestamp)
})
test('d2s should format DateTime for display', () => {
const result = d2s({ dateTime: testDateTime });
expect(result).toBeString()
const customFormat = d2s({ dateTime: testDateTime, format: 'yyyy-MM-dd' });
expect(customFormat).toBe('2024-01-01')
})
test('d2sDate should format DateTime as date string', () => {
const result = d2sDate({ dateTime: testDateTime });
expect(result).toBeString()
})
test('d2n should convert DateTime to milliseconds string', () => {
const result = d2n({ dateTime: testDateTime });
expect(result).toBe('1704067200000')
})
})
describe('isSameDate', () => {
test('should compare dates correctly', () => {
const date1 = DateTime.fromISO('2024-01-01T12:00:00Z');
const date2 = DateTime.fromISO('2024-01-01T15:00:00Z');
const date3 = DateTime.fromISO('2024-01-02T12:00:00Z');
expect(isSameDate(date1, date2)).toBe(true)
expect(isSameDate(date1, date3)).toBe(false)
})
})
}) })

View File

@@ -1,16 +1,57 @@
import { clsx, type ClassValue } from "clsx" import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge"
import moment from "moment-timezone" import { DateTime } from "luxon"
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
} }
export function getDateInTimezone(date: Date | string, timezone: string): Date { // get today's date string for timezone
const m = moment.tz(date, timezone); export function getTodayInTimezone(timezone: string): string {
return new Date(m.format('YYYY-MM-DDTHH:mm:ss.SSS[Z]')); const now = getNow({ timezone });
return d2s({ dateTime: now, format: 'yyyy-MM-dd' });
} }
export function getTodayInTimezone(timezone: string): string { // get datetime object of now
return moment.tz(timezone).format('YYYY-MM-DD'); export function getNow({ timezone = 'utc' }: { timezone?: string }) {
return DateTime.now().setZone(timezone);
}
// get current time in epoch milliseconds
export function getNowInMilliseconds() {
const now = getNow({});
return d2n({ dateTime: now });
}
// iso timestamp to datetime object, most for storage read
export function t2d({ timestamp, timezone }: { timestamp: string; timezone?: string }) {
return DateTime.fromISO(timestamp).setZone(timezone);
}
// convert datetime object to iso timestamp, mostly for storage write
export function d2t({ dateTime, timezone = 'utc' }: { dateTime: DateTime, timezone?: string }) {
return dateTime.setZone(timezone).toISO()!;
}
// convert datetime object to string, mostly for display
export function d2s({ dateTime, format }: { dateTime: DateTime, format?: string }) {
if (format) {
return dateTime.toFormat(format);
}
return dateTime.toLocaleString(DateTime.DATETIME_MED);
}
// convert datetime object to date string, mostly for display
export function d2sDate({ dateTime }: { dateTime: DateTime }) {
return dateTime.toLocaleString(DateTime.DATE_MED);
}
// convert datetime object to epoch milliseconds string, mostly for storage write
export function d2n({ dateTime }: { dateTime: DateTime }) {
return dateTime.toMillis().toString();
}
// compare the date portion of two datetime objects (i.e. same year, month, day)
export function isSameDate(a: DateTime, b: DateTime) {
return a.hasSame(b, 'day');
} }

37
package-lock.json generated
View File

@@ -25,8 +25,7 @@
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"js-confetti": "^0.12.0", "js-confetti": "^0.12.0",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"moment": "^2.30.1", "luxon": "^3.5.0",
"moment-timezone": "^0.5.46",
"next": "15.1.3", "next": "15.1.3",
"react": "^19.0.0", "react": "^19.0.0",
"react-confetti": "^6.2.2", "react-confetti": "^6.2.2",
@@ -40,6 +39,7 @@
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "^0.5.15",
"@types/luxon": "^3.4.2",
"@types/node": "^20.17.10", "@types/node": "^20.17.10",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
@@ -1699,6 +1699,12 @@
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"dev": true "dev": true
}, },
"node_modules/@types/luxon": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz",
"integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==",
"dev": true
},
"node_modules/@types/mdast": { "node_modules/@types/mdast": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
@@ -5299,6 +5305,14 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/luxon": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz",
"integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==",
"engines": {
"node": ">=12"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -5953,25 +5967,6 @@
"node": ">=16 || 14 >=14.17" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",

View File

@@ -28,8 +28,7 @@
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"js-confetti": "^0.12.0", "js-confetti": "^0.12.0",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"moment": "^2.30.1", "luxon": "^3.5.0",
"moment-timezone": "^0.5.46",
"next": "15.1.3", "next": "15.1.3",
"react": "^19.0.0", "react": "^19.0.0",
"react-confetti": "^6.2.2", "react-confetti": "^6.2.2",
@@ -43,6 +42,7 @@
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "^0.5.15",
"@types/luxon": "^3.4.2",
"@types/node": "^20.17.10", "@types/node": "^20.17.10",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",