8 Commits

Author SHA1 Message Date
ac9c13c9d3 Merge pull request 'fix: Maintenance dates display one day off due to timezone conversion (#237)' (#238) from issue-237-fix-date-timezone into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 18s
Deploy to Staging / Deploy to Staging (push) Successful in 23s
Deploy to Staging / Verify Staging (push) Successful in 4s
Deploy to Staging / Notify Staging Ready (push) Successful in 4s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Mirror Base Images / Mirror Base Images (push) Successful in 39s
Reviewed-on: #238
2026-03-24 12:54:59 +00:00
Eric Gullickson
1e056f0b01 fix: replace new Date() with dayjs for DATE column display and sorting (refs #237)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 1m19s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 44s
Deploy to Staging / Verify Staging (pull_request) Successful in 4s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 4s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
new Date("YYYY-MM-DD") parses as UTC midnight per ES2015. toLocaleDateString()
then displays in local time, shifting the date back one day for users west of
UTC. This caused the list view and edit dialog to show different dates.

Fixed in: MaintenanceRecordsList (display + sort + delete confirm),
VehicleDetailPage (display + sort), VehicleDetailMobile (display + sort),
MaintenanceRecordForm (receipt title), OwnershipCostsList (formatDate).

Sorting now uses string comparison (YYYY-MM-DD is lexicographically sortable).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:18:30 -05:00
Eric Gullickson
087f7b9fa5 fix: replace toISOString date conversion in OCR parser with local time formatting (refs #237)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 1m20s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 44s
Deploy to Staging / Verify Staging (pull_request) Successful in 4s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 4s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
The parseServiceDate function used toISOString().split('T')[0] which converts
to UTC, shifting dates by one day depending on timezone. Standard parsing now
uses getFullYear/getMonth/getDate (local time). MM/DD/YYYY parsing now formats
directly from regex groups without round-tripping through a Date object.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 20:56:34 -05:00
Eric Gullickson
f0fc427ccd fix: Date picker bug
All checks were successful
Deploy to Staging / Build Images (push) Successful in 1m21s
Deploy to Staging / Deploy to Staging (push) Successful in 43s
Deploy to Staging / Verify Staging (push) Successful in 4s
Deploy to Staging / Notify Staging Ready (push) Successful in 4s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
2026-03-23 20:03:49 -05:00
Eric Gullickson
c05e33e230 fix: Date picker bug
All checks were successful
Deploy to Staging / Build Images (push) Successful in 1m18s
Deploy to Staging / Deploy to Staging (push) Successful in 16s
Deploy to Staging / Verify Staging (push) Successful in 4s
Deploy to Staging / Notify Staging Ready (push) Successful in 4s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
2026-03-23 19:53:49 -05:00
Eric Gullickson
8955baae26 fix: UX Bug on maintenance page
All checks were successful
Deploy to Staging / Build Images (push) Successful in 1m18s
Deploy to Staging / Deploy to Staging (push) Successful in 44s
Deploy to Staging / Verify Staging (push) Successful in 4s
Deploy to Staging / Notify Staging Ready (push) Successful in 4s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
2026-03-23 19:44:01 -05:00
Eric Gullickson
068bb751a7 fix: Missing packages
All checks were successful
Deploy to Staging / Build Images (push) Successful in 15s
Deploy to Staging / Deploy to Staging (push) Successful in 44s
Deploy to Staging / Verify Staging (push) Successful in 4s
Deploy to Staging / Notify Staging Ready (push) Successful in 4s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
2026-03-22 12:33:55 -05:00
Eric Gullickson
cd7f8f56d8 fix: Missing packages 2026-03-22 12:33:35 -05:00
13 changed files with 67 additions and 33 deletions

View File

@@ -34,6 +34,7 @@ make migrate # run DB migrations
- View which container images are running: `docker ps --format 'table {{.Names}}\t{{.Image}}'` - View which container images are running: `docker ps --format 'table {{.Names}}\t{{.Image}}'`
- Flush all redis cache: `docker compose exec -T mvp-redis sh -lc "redis-cli FLUSHALL"` - Flush all redis cache: `docker compose exec -T mvp-redis sh -lc "redis-cli FLUSHALL"`
- Flush all backup data on staging before restoring: `docker compose exec mvp-postgres psql -U postgres -d motovaultpro -c "TRUNCATE TABLE backup_history, backup_schedules, backup_settings RESTART IDENTITY CASCADE;"` - Flush all backup data on staging before restoring: `docker compose exec mvp-postgres psql -U postgres -d motovaultpro -c "TRUNCATE TABLE backup_history, backup_schedules, backup_settings RESTART IDENTITY CASCADE;"`
- Create new admin users on brand new deployment `docker exec -it motovaultpro-backend-1 node dist/_system/cli/create-admin.js`
## Development Workflow ## Development Workflow

View File

@@ -105,10 +105,20 @@
# ============================================ # ============================================
# act_runner Installation # act_runner Installation
# ============================================ # ============================================
- name: Check current act_runner version
command: act_runner --version
register: current_runner_version
changed_when: false
failed_when: false
- name: Download act_runner binary - name: Download act_runner binary
get_url: shell: curl -fsSL -o /usr/local/bin/act_runner "https://gitea.com/gitea/act_runner/releases/download/v{{ act_runner_version }}/act_runner-{{ act_runner_version }}-linux-amd64"
url: "https://gitea.com/gitea/act_runner/releases/download/v{{ act_runner_version }}/act_runner-{{ act_runner_version }}-linux-amd64" when: current_runner_version.rc != 0 or act_runner_version not in (current_runner_version.stdout | default(''))
dest: /usr/local/bin/act_runner notify: Restart act_runner
- name: Set act_runner binary permissions
file:
path: /usr/local/bin/act_runner
mode: '0755' mode: '0755'
- name: Verify act_runner installation - name: Verify act_runner installation

View File

@@ -136,10 +136,20 @@
# ============================================ # ============================================
# act_runner Installation # act_runner Installation
# ============================================ # ============================================
- name: Check current act_runner version
command: act_runner --version
register: current_runner_version
changed_when: false
failed_when: false
- name: Download act_runner binary - name: Download act_runner binary
get_url: shell: curl -fsSL -o /usr/local/bin/act_runner "https://gitea.com/gitea/act_runner/releases/download/v{{ act_runner_version }}/act_runner-{{ act_runner_version }}-linux-amd64"
url: "https://gitea.com/gitea/act_runner/releases/download/v{{ act_runner_version }}/act_runner-{{ act_runner_version }}-linux-amd64" when: current_runner_version.rc != 0 or act_runner_version not in (current_runner_version.stdout | default(''))
dest: /usr/local/bin/act_runner notify: Restart act_runner
- name: Set act_runner binary permissions
file:
path: /usr/local/bin/act_runner
mode: '0755' mode: '0755'
- name: Verify act_runner installation - name: Verify act_runner installation

View File

@@ -2,10 +2,15 @@
* @ai-summary PostgreSQL connection pool configuration * @ai-summary PostgreSQL connection pool configuration
* @ai-context Shared pool for all feature repositories * @ai-context Shared pool for all feature repositories
*/ */
import { Pool } from 'pg'; import { Pool, types } from 'pg';
import { logger } from '../logging/logger'; import { logger } from '../logging/logger';
import { appConfig } from './config-loader'; import { appConfig } from './config-loader';
// Override DATE type parser to return plain YYYY-MM-DD strings instead of Date objects.
// Default pg behavior creates Date objects at local midnight, which shift dates when
// serialized to JSON via toISOString() (UTC conversion) for clients in other timezones.
types.setTypeParser(1082, (val: string) => val);
export const pool = new Pool({ export const pool = new Pool({
connectionString: appConfig.getDatabaseUrl(), connectionString: appConfig.getDatabaseUrl(),
max: 10, max: 10,

View File

@@ -335,9 +335,9 @@ export const MaintenanceRecordEditDialog: React.FC<MaintenanceRecordEditDialogPr
<Grid item xs={12} sm={6}> <Grid item xs={12} sm={6}>
<DatePicker <DatePicker
label="Service Date *" label="Service Date *"
value={formData.date ? dayjs(formData.date) : null} value={formData.date ? dayjs(String(formData.date).substring(0, 10)) : null}
onChange={(newValue) => onChange={(newValue) =>
handleInputChange('date', newValue?.toISOString().split('T')[0] || '') handleInputChange('date', newValue?.format('YYYY-MM-DD') || '')
} }
format="MM/DD/YYYY" format="MM/DD/YYYY"
slotProps={{ slotProps={{

View File

@@ -111,9 +111,9 @@ export const MaintenanceRecordForm: React.FC<MaintenanceRecordFormProps> = ({ ve
mode: 'onChange', mode: 'onChange',
defaultValues: { defaultValues: {
vehicle_id: vehicleId || '', vehicle_id: vehicleId || '',
category: undefined as any, category: '' as any,
subtypes: [], subtypes: [],
date: new Date().toISOString().split('T')[0], date: dayjs().format('YYYY-MM-DD'),
odometer_reading: '' as any, odometer_reading: '' as any,
cost: '' as any, cost: '' as any,
shop_name: '', shop_name: '',
@@ -132,6 +132,8 @@ export const MaintenanceRecordForm: React.FC<MaintenanceRecordFormProps> = ({ ve
if (watchedCategory) { if (watchedCategory) {
setSelectedCategory(watchedCategory as MaintenanceCategory); setSelectedCategory(watchedCategory as MaintenanceCategory);
setValue('subtypes', []); setValue('subtypes', []);
} else {
setSelectedCategory(null);
} }
}, [watchedCategory, setValue]); }, [watchedCategory, setValue]);
@@ -188,7 +190,7 @@ export const MaintenanceRecordForm: React.FC<MaintenanceRecordFormProps> = ({ ve
const doc = await documentsApi.create({ const doc = await documentsApi.create({
vehicleId: data.vehicle_id, vehicleId: data.vehicle_id,
documentType: 'manual', documentType: 'manual',
title: `Maintenance Receipt - ${new Date(data.date).toLocaleDateString()}`, title: `Maintenance Receipt - ${dayjs(data.date).format('M/D/YYYY')}`,
}); });
await documentsApi.upload(doc.id, capturedReceiptFile); await documentsApi.upload(doc.id, capturedReceiptFile);
receiptDocumentId = doc.id; receiptDocumentId = doc.id;
@@ -217,9 +219,9 @@ export const MaintenanceRecordForm: React.FC<MaintenanceRecordFormProps> = ({ ve
// Reset form // Reset form
reset({ reset({
vehicle_id: '', vehicle_id: '',
category: undefined as any, category: '' as any,
subtypes: [], subtypes: [],
date: new Date().toISOString().split('T')[0], date: dayjs().format('YYYY-MM-DD'),
odometer_reading: '' as any, odometer_reading: '' as any,
cost: '' as any, cost: '' as any,
shop_name: '', shop_name: '',
@@ -385,7 +387,7 @@ export const MaintenanceRecordForm: React.FC<MaintenanceRecordFormProps> = ({ ve
label="Date *" label="Date *"
value={field.value ? dayjs(field.value) : null} value={field.value ? dayjs(field.value) : null}
onChange={(newValue) => onChange={(newValue) =>
field.onChange(newValue?.toISOString().split('T')[0] || '') field.onChange(newValue?.format('YYYY-MM-DD') || '')
} }
format="MM/DD/YYYY" format="MM/DD/YYYY"
slotProps={{ slotProps={{

View File

@@ -4,6 +4,7 @@
*/ */
import React, { useState } from 'react'; import React, { useState } from 'react';
import dayjs from 'dayjs';
import { import {
Card, Card,
CardContent, CardContent,
@@ -74,14 +75,14 @@ export const MaintenanceRecordsList: React.FC<MaintenanceRecordsListProps> = ({
// Sort records by date DESC (newest first) // Sort records by date DESC (newest first)
const sortedRecords = [...records].sort( const sortedRecords = [...records].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime() (a, b) => b.date.localeCompare(a.date)
); );
return ( return (
<> <>
<Stack spacing={2}> <Stack spacing={2}>
{sortedRecords.map((record) => { {sortedRecords.map((record) => {
const dateText = new Date(record.date).toLocaleDateString(); const dateText = dayjs(record.date).format('M/D/YYYY');
const categoryDisplay = getCategoryDisplayName(record.category); const categoryDisplay = getCategoryDisplayName(record.category);
const subtypeCount = record.subtypeCount || record.subtypes?.length || 0; const subtypeCount = record.subtypeCount || record.subtypes?.length || 0;
@@ -204,7 +205,7 @@ export const MaintenanceRecordsList: React.FC<MaintenanceRecordsListProps> = ({
</Typography> </Typography>
{recordToDelete && ( {recordToDelete && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}> <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
{new Date(recordToDelete.date).toLocaleDateString()} -{' '} {dayjs(recordToDelete.date).format('M/D/YYYY')} -{' '}
{getCategoryDisplayName(recordToDelete.category)} {getCategoryDisplayName(recordToDelete.category)}
</Typography> </Typography>
)} )}

View File

@@ -335,9 +335,9 @@ export const MaintenanceScheduleEditDialog: React.FC<MaintenanceScheduleEditDial
<Grid item xs={12}> <Grid item xs={12}>
<DatePicker <DatePicker
label="Due Date" label="Due Date"
value={formData.fixedDueDate ? dayjs(formData.fixedDueDate) : null} value={formData.fixedDueDate ? dayjs(String(formData.fixedDueDate).substring(0, 10)) : null}
onChange={(newValue) => onChange={(newValue) =>
handleInputChange('fixedDueDate', newValue?.toISOString().split('T')[0] || undefined) handleInputChange('fixedDueDate', newValue?.format('YYYY-MM-DD') || undefined)
} }
format="MM/DD/YYYY" format="MM/DD/YYYY"
slotProps={{ slotProps={{

View File

@@ -119,7 +119,7 @@ export const MaintenanceScheduleForm: React.FC<MaintenanceScheduleFormProps> = (
mode: 'onChange', mode: 'onChange',
defaultValues: { defaultValues: {
vehicle_id: vehicleId || '', vehicle_id: vehicleId || '',
category: undefined as any, category: '' as any,
subtypes: [], subtypes: [],
schedule_type: 'interval' as ScheduleType, schedule_type: 'interval' as ScheduleType,
interval_months: '' as any, interval_months: '' as any,
@@ -145,6 +145,8 @@ export const MaintenanceScheduleForm: React.FC<MaintenanceScheduleFormProps> = (
if (watchedCategory) { if (watchedCategory) {
setSelectedCategory(watchedCategory as MaintenanceCategory); setSelectedCategory(watchedCategory as MaintenanceCategory);
setValue('subtypes', []); setValue('subtypes', []);
} else {
setSelectedCategory(null);
} }
}, [watchedCategory, setValue]); }, [watchedCategory, setValue]);
@@ -170,7 +172,7 @@ export const MaintenanceScheduleForm: React.FC<MaintenanceScheduleFormProps> = (
// Reset form // Reset form
reset({ reset({
vehicle_id: '', vehicle_id: '',
category: undefined as any, category: '' as any,
subtypes: [], subtypes: [],
schedule_type: 'interval' as ScheduleType, schedule_type: 'interval' as ScheduleType,
interval_months: '' as any, interval_months: '' as any,
@@ -410,7 +412,7 @@ export const MaintenanceScheduleForm: React.FC<MaintenanceScheduleFormProps> = (
label="Fixed Due Date *" label="Fixed Due Date *"
value={field.value ? dayjs(field.value) : null} value={field.value ? dayjs(field.value) : null}
onChange={(newValue) => onChange={(newValue) =>
field.onChange(newValue?.toISOString().split('T')[0] || '') field.onChange(newValue?.format('YYYY-MM-DD') || '')
} }
format="MM/DD/YYYY" format="MM/DD/YYYY"
slotProps={{ slotProps={{

View File

@@ -107,17 +107,17 @@ function parseServiceDate(value: string | number | null): string | undefined {
// Try standard parsing // Try standard parsing
const date = new Date(dateStr); const date = new Date(dateStr);
if (!isNaN(date.getTime())) { if (!isNaN(date.getTime())) {
return date.toISOString().split('T')[0]; const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
} }
// Try MM/DD/YYYY format // Try MM/DD/YYYY format
const mdyMatch = dateStr.match(/(\d{1,2})\/(\d{1,2})\/(\d{4})/); const mdyMatch = dateStr.match(/(\d{1,2})\/(\d{1,2})\/(\d{4})/);
if (mdyMatch) { if (mdyMatch) {
const [, month, day, year] = mdyMatch; const [, month, day, year] = mdyMatch;
const parsed = new Date(parseInt(year), parseInt(month) - 1, parseInt(day)); return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
if (!isNaN(parsed.getTime())) {
return parsed.toISOString().split('T')[0];
}
} }
return undefined; return undefined;

View File

@@ -3,6 +3,7 @@
*/ */
import React from 'react'; import React from 'react';
import dayjs from 'dayjs';
import type { OwnershipCost, OwnershipCostType } from '../types/ownership-costs.types'; import type { OwnershipCost, OwnershipCostType } from '../types/ownership-costs.types';
import { useOwnershipCostsList, useDeleteOwnershipCost } from '../hooks/useOwnershipCosts'; import { useOwnershipCostsList, useDeleteOwnershipCost } from '../hooks/useOwnershipCosts';
@@ -42,7 +43,7 @@ export const OwnershipCostsList: React.FC<OwnershipCostsListProps> = ({ vehicleI
}; };
const formatDate = (dateString: string): string => { const formatDate = (dateString: string): string => {
return new Date(dateString).toLocaleDateString(); return dayjs(dateString).format('M/D/YYYY');
}; };
if (isLoading) { if (isLoading) {

View File

@@ -3,6 +3,7 @@
*/ */
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import dayjs from 'dayjs';
import { Box, Typography, Button, Card, CardContent, Divider, FormControl, InputLabel, Select, MenuItem, List, ListItem, ListItemButton, Dialog, DialogTitle, DialogContent } from '@mui/material'; import { Box, Typography, Button, Card, CardContent, Divider, FormControl, InputLabel, Select, MenuItem, List, ListItem, ListItemButton, Dialog, DialogTitle, DialogContent } from '@mui/material';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { Vehicle } from '../types/vehicles.types'; import { Vehicle } from '../types/vehicles.types';
@@ -123,7 +124,7 @@ export const VehicleDetailMobile: React.FC<VehicleDetailMobileProps> = ({
}); });
} }
} }
return list.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); return list.sort((a, b) => b.date.localeCompare(a.date));
}, [fuelLogs]); }, [fuelLogs]);
const filteredRecords = useMemo(() => { const filteredRecords = useMemo(() => {
@@ -286,7 +287,7 @@ export const VehicleDetailMobile: React.FC<VehicleDetailMobileProps> = ({
</Box> </Box>
{/* Secondary line: Grade • Date • Type */} {/* Secondary line: Grade • Date • Type */}
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}> <Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>
{rec.secondary || `${new Date(rec.date).toLocaleDateString()}${rec.type}`} {rec.secondary || `${dayjs(rec.date).format('M/D/YYYY')}${rec.type}`}
</Typography> </Typography>
</Box> </Box>
</ListItemButton> </ListItemButton>

View File

@@ -3,6 +3,7 @@
*/ */
import React, { useMemo, useState, useEffect } from 'react'; import React, { useMemo, useState, useEffect } from 'react';
import dayjs from 'dayjs';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'; import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { Box, Typography, Button as MuiButton, Divider, FormControl, InputLabel, Select, MenuItem, Table, TableHead, TableRow, TableCell, TableBody, Dialog, DialogTitle, DialogContent, useMediaQuery, IconButton } from '@mui/material'; import { Box, Typography, Button as MuiButton, Divider, FormControl, InputLabel, Select, MenuItem, Table, TableHead, TableRow, TableCell, TableBody, Dialog, DialogTitle, DialogContent, useMediaQuery, IconButton } from '@mui/material';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
@@ -132,7 +133,7 @@ export const VehicleDetailPage: React.FC = () => {
} }
} }
return list.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); return list.sort((a, b) => b.date.localeCompare(a.date));
}, [fuelLogs, documents]); }, [fuelLogs, documents]);
const filteredRecords = useMemo(() => { const filteredRecords = useMemo(() => {
@@ -490,7 +491,7 @@ export const VehicleDetailPage: React.FC = () => {
)} )}
{!isFuelLoading && !isDocumentsLoading && filteredRecords.map((rec) => ( {!isFuelLoading && !isDocumentsLoading && filteredRecords.map((rec) => (
<TableRow key={rec.id} hover sx={{ cursor: rec.type === 'Documents' ? 'default' : 'pointer' }} onClick={() => handleRowClick(rec.id, rec.type)}> <TableRow key={rec.id} hover sx={{ cursor: rec.type === 'Documents' ? 'default' : 'pointer' }} onClick={() => handleRowClick(rec.id, rec.type)}>
<TableCell>{new Date(rec.date).toLocaleDateString()}</TableCell> <TableCell>{dayjs(rec.date).format('M/D/YYYY')}</TableCell>
<TableCell>{rec.type}</TableCell> <TableCell>{rec.type}</TableCell>
<TableCell>{rec.summary}</TableCell> <TableCell>{rec.summary}</TableCell>
<TableCell align="right">{rec.amount || '—'}</TableCell> <TableCell align="right">{rec.amount || '—'}</TableCell>