Open Finapp
Development

Date Utilities

Date range computation, period formatting, and interval bucketing.

Date utilities handle period selection, range computation, interval generation, and date formatting for statistics.

Key Files

FilePurpose
app/components/date/utils.tsCore utilities: toDuration, getStartOf, getEndOf, interval computation
app/components/date/types.tsTypes: Period, Range, StatDateParams, IntervalsInRangeProps
app/components/date/statDateParams.tsRange computation from params, query param parsing
app/components/date/useStatDate.tsComposable: reactive date state with localStorage persistence
app/components/stat/date/useGetDateRange.tsHuman-readable date labels ("Today", "Last 3 months", "Mar - Jun")
app/components/stat/intervals.tsTransaction bucketing into intervals, averages

Period System

All date operations use the Period union type:

const periods = ['day', 'week', 'month', 'year'] as const
type Period = typeof periods[number]

Three core helpers convert Period into date-fns operations:

toDuration(period, value)   // Period -> Duration ({ days: 3 }, { months: 1 })
getStartOf(date, period)    // Period -> start boundary (startOfMonth, startOfWeek, ...)
getEndOf(date, period)      // Period -> end boundary (endOfMonth, endOfWeek, ...)

All three use exhaustive switch - adding a new period variant causes a compile error, not silent fallback.

Type-safe Duration

toDuration replaces dynamic property keys with a proper Duration return type:

// Before: loses type safety, TS sees Record<string, number>
sub(date, { [`${period}s`]: value })

// After: returns typed Duration
sub(date, toDuration(period, value))

Date Range Computation

computeDateRange() resolves StatDateParams into a concrete { start, end } range:

Priority:
1. customDate (calendar picker)      -> return as-is
2. isShowMaxRange                    -> maxRange.start to end-of-current-period
3. isShowMaxRange + isSkipEmpty      -> maxRange as-is
4. calculated                        -> from Date.now() using rangeBy/rangeDuration/rangeOffset

The range is a Vue computed that uses Date.now() as the base. Since Date.now() is not reactive, the range updates only when params or maxRange change (not automatically at midnight). Page refresh or any param change triggers recomputation.

Interval Generation

getIntervalsInRange() splits a date range into sub-intervals:

range: Mar 1 - Mar 31, intervalsBy: 'week'
-> [Mar 1-2, Mar 3-9, Mar 10-16, Mar 17-23, Mar 24-30, Mar 31]

Edge intervals are clamped to the range boundaries.

Performance

Intervals are collected with push() then reverse() instead of unshift() in a loop. unshift shifts all elements on every call (O(n^2) for the loop), while push + reverse is O(n).

Transaction Bucketing

bucketTrnsByIntervals() assigns transactions to intervals using binary search:

// O(N log M) where N = transactions, M = intervals
for each transaction:
  binary search intervals by date
  push into matching bucket

Intervals are sorted and non-overlapping, making binary search valid. Transactions outside any interval are silently skipped.

Date Formatting

useGetDateRange() produces human-readable labels. Each format function returns the complete string directly (no start/end halves to concatenate):

duration=1, current period  -> "Today", "This Month", "This Year"
duration=1, previous period -> "Yesterday", "Last Month", "Last Year"
duration=1, same year       -> "15 March"
duration>1, current period  -> "Last 3 days", "Last 6 months"
otherwise                   -> "Mar - Jun", "10-15 Mar 2024"

Period matching uses isSamePeriod(date1, date2, period) - a single helper replacing repeated switch blocks.

Query Param Parsing

parseStatDateQueryParams() merges URL query params into StatDateParams using Zod validation. All fields use !== undefined checks (not falsy) so that rangeOffset=0 and intervalSelected=0 are applied correctly.

Average Computation

computeAverageTotal() computes per-day/week/month averages for a date range. Returns undefined for single-day ranges or zero sums (early exit before computing intervals). The day average is always present when the function returns a result (range >= 2 days guaranteed), so no empty-object check is needed.

Next Steps