mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(client): compute "today" from the local calendar, not UTC
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { localIsoDate } from '../../utils/localDate'
|
||||
import { ArrowLeft, ChevronRight, Calendar } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
@@ -81,7 +82,7 @@ export function DatePicker({ value, onChange, tripDates }: {
|
||||
const dateStr = `${viewMonth.year}-${pad(viewMonth.month + 1)}-${pad(day)}`
|
||||
const isSelected = dateStr === value
|
||||
const isTrip = tripDates?.has(dateStr)
|
||||
const isToday = dateStr === new Date().toISOString().split('T')[0]
|
||||
const isToday = dateStr === localIsoDate()
|
||||
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { http, HttpResponse, delay } from 'msw'
|
||||
import { localIsoDate } from '../../utils/localDate'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
@@ -516,7 +517,7 @@ describe('EntryEditor', () => {
|
||||
entry_date: '', photos: undefined as unknown as JourneyPhoto[],
|
||||
}))
|
||||
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const today = localIsoDate() // local, matching the editor's default — not the UTC date
|
||||
const label = new Date(today + 'T00:00:00').toLocaleDateString(undefined, {
|
||||
month: 'short', day: 'numeric', year: 'numeric',
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState, useRef } from 'react'
|
||||
import { localIsoDate } from '../../utils/localDate'
|
||||
import { X, Plus, Image, Minus, Check, MapPin, Locate, Camera } from 'lucide-react'
|
||||
import { normalizeImageFiles } from '../../utils/convertHeic'
|
||||
import { type ResilientResult, type UploadProgress } from '../../utils/uploadQueue'
|
||||
@@ -33,7 +34,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
const toast = useToast()
|
||||
const [title, setTitle] = useState(entry.title || '')
|
||||
const [story, setStory] = useState(entry.story || '')
|
||||
const [entryDate, setEntryDate] = useState(entry.entry_date || new Date().toISOString().split('T')[0])
|
||||
const [entryDate, setEntryDate] = useState(entry.entry_date || localIsoDate())
|
||||
const [entryTime, setEntryTime] = useState(entry.entry_time?.slice(0, 5) || '')
|
||||
const [locationName, setLocationName] = useState(entry.location_name || '')
|
||||
const [locationLat, setLocationLat] = useState<number | null>(entry.location_lat ?? null)
|
||||
@@ -78,7 +79,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
const isDirty = (
|
||||
title !== (entry.title || '') ||
|
||||
story !== (entry.story || '') ||
|
||||
entryDate !== (entry.entry_date || new Date().toISOString().split('T')[0]) ||
|
||||
entryDate !== (entry.entry_date || localIsoDate()) ||
|
||||
entryTime !== (entry.entry_time?.slice(0, 5) || '') ||
|
||||
locationName !== (entry.location_name || '') ||
|
||||
(locationLat ?? null) !== (entry.location_lat ?? null) ||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { localIsoDate } from '../../utils/localDate'
|
||||
import { useParams } from 'react-router'
|
||||
import apiClient from '../../api/client'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
@@ -480,7 +481,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
onChange={tm => {
|
||||
const [d] = (form.reservation_time || '').split('T')
|
||||
const selectedDay = days.find(dy => dy.id === selectedDayId)
|
||||
const date = d || selectedDay?.date || new Date().toISOString().split('T')[0]
|
||||
const date = d || selectedDay?.date || localIsoDate()
|
||||
set('reservation_time', tm ? `${date}T${tm}` : date)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { localIsoDate } from '../../utils/localDate';
|
||||
import { act, fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { CustomDatePicker, CustomDateTimePicker } from './CustomDateTimePicker';
|
||||
@@ -722,7 +723,7 @@ describe('CustomDateTimePicker branches', () => {
|
||||
const steppers = screen.getAllByRole('button').filter((b) => b.textContent?.trim() === '');
|
||||
fireEvent.click(steppers[steppers.length - 4]);
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const today = localIsoDate(); // the picker falls back to the LOCAL date, not the UTC one
|
||||
expect(onChange).toHaveBeenCalledWith(`${today}T01:00`);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Calendar, ChevronLeft, ChevronRight, Keyboard } from 'lucide-react';
|
||||
import { localIsoDate } from '../../utils/localDate';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from '../../i18n';
|
||||
@@ -688,7 +689,7 @@ export function CustomDateTimePicker({ value, onChange, placeholder, style = {}
|
||||
onChange(d ? `${d}T${timePart || '12:00'}` : '');
|
||||
};
|
||||
const handleTimeChange = (t: string) => {
|
||||
const d = datePart || new Date().toISOString().split('T')[0];
|
||||
const d = datePart || localIsoDate();
|
||||
onChange(t ? `${d}T${t}` : d);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { localIsoDate } from '../../../utils/localDate'
|
||||
import { Camera, Plus, Image, Images, X, MapPin, Locate, Trash2, CheckCircle2, MinusCircle } from 'lucide-react'
|
||||
import MSheet from '../../components/MSheet'
|
||||
import MIconBtn from '../../components/MIconBtn'
|
||||
@@ -57,7 +58,7 @@ export default function MJourneyEntrySheet({
|
||||
|
||||
const [title, setTitle] = useState(entry.title || '')
|
||||
const [story, setStory] = useState(entry.story || '')
|
||||
const [entryDate, setEntryDate] = useState(entry.entry_date || new Date().toISOString().split('T')[0])
|
||||
const [entryDate, setEntryDate] = useState(entry.entry_date || localIsoDate())
|
||||
const [entryTime, setEntryTime] = useState(entry.entry_time?.slice(0, 5) || '')
|
||||
const [locationName, setLocationName] = useState(entry.location_name || '')
|
||||
const [locationLat, setLocationLat] = useState<number | null>(entry.location_lat ?? null)
|
||||
@@ -177,7 +178,7 @@ export default function MJourneyEntrySheet({
|
||||
const isDirty =
|
||||
title !== (entry.title || '') ||
|
||||
story !== (entry.story || '') ||
|
||||
entryDate !== (entry.entry_date || new Date().toISOString().split('T')[0]) ||
|
||||
entryDate !== (entry.entry_date || localIsoDate()) ||
|
||||
entryTime !== (entry.entry_time?.slice(0, 5) || '') ||
|
||||
locationName !== (entry.location_name || '') ||
|
||||
mood !== (entry.mood || '') ||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { localIsoDate } from '../utils/localDate';
|
||||
import { render, screen, waitFor, within } from '../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -535,9 +536,9 @@ describe('DashboardPage', () => {
|
||||
|
||||
describe('FE-PAGE-DASH-023: SpotlightCard shows progress bar for ongoing trip', () => {
|
||||
it('renders progress bar and live badge when trip is currently ongoing', async () => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0];
|
||||
const nextWeek = new Date(Date.now() + 7 * 86400000).toISOString().split('T')[0];
|
||||
const today = localIsoDate();
|
||||
const yesterday = localIsoDate(new Date(Date.now() - 86400000));
|
||||
const nextWeek = localIsoDate(new Date(Date.now() + 7 * 86400000));
|
||||
|
||||
const ongoingTrip = buildTrip({
|
||||
title: 'Current Voyage',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { localIsoDate } from '../utils/localDate';
|
||||
import { render, screen, waitFor, cleanup } from '../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -1317,7 +1318,7 @@ describe('JourneyDetailPage', () => {
|
||||
http.post('/api/journeys/1/entries', () => {
|
||||
return HttpResponse.json({
|
||||
id: 99, journey_id: 1, author_id: 1, type: 'entry',
|
||||
entry_date: new Date().toISOString().split('T')[0],
|
||||
entry_date: localIsoDate(),
|
||||
title: 'Test Entry', story: null, location_name: null,
|
||||
location_lat: null, location_lng: null, mood: null, weather: null,
|
||||
tags: [], pros_cons: null, visibility: 'private', sort_order: 0,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import PageShell from '../components/Layout/PageShell'
|
||||
import { localIsoDate } from '../utils/localDate'
|
||||
import { useTranslation, TransHtml } from '../i18n'
|
||||
import {
|
||||
Plus, Search, Sparkles, Calendar, MapPin,
|
||||
@@ -271,9 +272,9 @@ function JourneyPageDesktop() {
|
||||
<div className="flex flex-col gap-2 max-h-[320px] overflow-y-auto">
|
||||
{availableTrips.map(trip => {
|
||||
const selected = selectedTripIds.has(trip.id)
|
||||
const status = trip.end_date && trip.end_date < new Date().toISOString().split('T')[0]
|
||||
const status = trip.end_date && trip.end_date < localIsoDate()
|
||||
? 'completed'
|
||||
: trip.start_date && trip.start_date <= new Date().toISOString().split('T')[0]
|
||||
: trip.start_date && trip.start_date <= localIsoDate()
|
||||
? 'active'
|
||||
: 'upcoming'
|
||||
const statusColors: Record<string, string> = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// FE-JRN-LISTWIRE-001 to FE-JRN-LISTWIRE-014
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { localIsoDate } from '../utils/localDate';
|
||||
import { render, screen, fireEvent } from '../../tests/helpers/render';
|
||||
import type { Journey } from '../store/journeyStore';
|
||||
import JourneyPage from './JourneyPage';
|
||||
@@ -182,7 +183,7 @@ describe('JourneyPage wiring', () => {
|
||||
});
|
||||
|
||||
it('FE-JRN-LISTWIRE-014: modal trips render their status and toggle their selection', () => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const today = localIsoDate(); // local — the status chips classify against the wall clock
|
||||
const availableTrips = [
|
||||
{ id: 1, title: 'Past trip', start_date: '2020-01-01', end_date: '2020-01-05', place_count: 3, cover_image: '/uploads/t.jpg' },
|
||||
{ id: 2, title: 'Running trip', start_date: today, end_date: '2099-01-01', place_count: 1 },
|
||||
|
||||
@@ -1,97 +1,51 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { daysUntil, getTripStatus, sortTrips, MS_PER_DAY, type DashboardTrip } from './dashboardModel';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { daysUntil, getTripStatus, sortTrips, localIsoToday } from './dashboardModel'
|
||||
|
||||
// FE-HOOK-DASHMODEL-001 onwards
|
||||
/**
|
||||
* The dashboard classifies trips against the user's WALL CLOCK — `daysUntil`
|
||||
* always did (it parses dates as local midnight). But the "ongoing" check and
|
||||
* the sort ranking derived "today" from `toISOString()`, which is UTC — so in
|
||||
* any non-UTC timezone, between local midnight and the UTC rollover, a trip
|
||||
* that ended yesterday still ranked as "running" and a trip starting today
|
||||
* wasn't "ongoing". These tests pin the clock inside that window (00:30 local).
|
||||
* On a UTC machine local == UTC and the old bug is unobservable, so the
|
||||
* regression cases below are vacuous there — they bite on any offset TZ.
|
||||
*/
|
||||
describe('dashboardModel — "today" is the user\'s local date', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
// Local 2026-08-25 00:30 — for any TZ ahead of UTC, the UTC date is still 08-24.
|
||||
vi.setSystemTime(new Date(2026, 7, 25, 0, 30))
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const trip = (over: Partial<DashboardTrip>): DashboardTrip =>
|
||||
({ id: 1, title: 't', ...over }) as unknown as DashboardTrip;
|
||||
it('localIsoToday() is the local calendar date, not the UTC one', () => {
|
||||
expect(localIsoToday()).toBe('2026-08-25')
|
||||
})
|
||||
|
||||
const offset = (days: number): string => {
|
||||
const d = new Date('2026-07-15T12:00:00Z');
|
||||
d.setUTCDate(d.getUTCDate() + days);
|
||||
return d.toISOString().split('T')[0];
|
||||
};
|
||||
it('daysUntil counts from local midnight', () => {
|
||||
expect(daysUntil('2026-08-25')).toBe(0)
|
||||
expect(daysUntil('2026-08-26')).toBe(1)
|
||||
expect(daysUntil('2026-08-24')).toBe(-1)
|
||||
expect(daysUntil(null)).toBeNull()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-07-15T12:00:00Z'));
|
||||
});
|
||||
it('a trip spanning the local today is ongoing', () => {
|
||||
expect(getTripStatus({ start_date: '2026-08-25', end_date: '2026-08-26' } as never)).toBe('ongoing')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
it('a trip starting on the local today (no end) is "today"', () => {
|
||||
expect(getTripStatus({ start_date: '2026-08-25', end_date: null } as never)).toBe('today')
|
||||
})
|
||||
|
||||
describe('daysUntil', () => {
|
||||
it('FE-HOOK-DASHMODEL-001: has no answer without a date', () => {
|
||||
expect(daysUntil(null)).toBeNull();
|
||||
expect(daysUntil(undefined)).toBeNull();
|
||||
});
|
||||
it('a trip that ended yesterday (local) is past — and sorts after one starting today', () => {
|
||||
expect(getTripStatus({ start_date: '2026-08-20', end_date: '2026-08-24' } as never)).toBe('past')
|
||||
|
||||
it('FE-HOOK-DASHMODEL-002: counts forwards and backwards from today', () => {
|
||||
expect(daysUntil(offset(0))).toBe(0);
|
||||
expect(daysUntil(offset(5))).toBe(5);
|
||||
expect(daysUntil(offset(-3))).toBe(-3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTripStatus', () => {
|
||||
it('FE-HOOK-DASHMODEL-003: a date range around today is ongoing', () => {
|
||||
expect(getTripStatus(trip({ start_date: offset(-2), end_date: offset(2) }))).toBe('ongoing');
|
||||
});
|
||||
|
||||
it('FE-HOOK-DASHMODEL-004: a trip without a start date has no status', () => {
|
||||
expect(getTripStatus(trip({ start_date: null, end_date: null }))).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[0, 'today'],
|
||||
[1, 'tomorrow'],
|
||||
[9, 'future'],
|
||||
[-9, 'past'],
|
||||
] as Array<[number, string]>)('FE-HOOK-DASHMODEL-005: a start in %s days is %s', (days, status) => {
|
||||
// No end date, so the "ongoing" rule cannot swallow the start-relative states.
|
||||
expect(getTripStatus(trip({ start_date: offset(days), end_date: null }))).toBe(status);
|
||||
});
|
||||
|
||||
it('FE-HOOK-DASHMODEL-010: a finished date range is past', () => {
|
||||
expect(getTripStatus(trip({ start_date: offset(-9), end_date: offset(-8) }))).toBe('past');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortTrips', () => {
|
||||
it('FE-HOOK-DASHMODEL-006: running first, then upcoming ascending, then finished descending', () => {
|
||||
const sorted = sortTrips([
|
||||
trip({ id: 1, start_date: offset(-40), end_date: offset(-30) }),
|
||||
trip({ id: 2, start_date: offset(20), end_date: offset(25) }),
|
||||
trip({ id: 3, start_date: offset(-1), end_date: offset(3) }),
|
||||
trip({ id: 4, start_date: offset(5), end_date: offset(8) }),
|
||||
trip({ id: 5, start_date: offset(-10), end_date: offset(-8) }),
|
||||
]);
|
||||
|
||||
expect(sorted.map(t => t.id)).toEqual([3, 4, 2, 5, 1]);
|
||||
});
|
||||
|
||||
it('FE-HOOK-DASHMODEL-007: leaves the input array untouched', () => {
|
||||
const input = [trip({ id: 1, start_date: offset(10) }), trip({ id: 2, start_date: offset(1) })];
|
||||
|
||||
const sorted = sortTrips(input);
|
||||
|
||||
expect(input.map(t => t.id)).toEqual([1, 2]);
|
||||
expect(sorted.map(t => t.id)).toEqual([2, 1]);
|
||||
});
|
||||
|
||||
it('FE-HOOK-DASHMODEL-008: dateless trips rank last', () => {
|
||||
const sorted = sortTrips([
|
||||
trip({ id: 1, start_date: null, end_date: null }),
|
||||
trip({ id: 2, start_date: offset(4), end_date: offset(6) }),
|
||||
]);
|
||||
|
||||
expect(sorted.map(t => t.id)).toEqual([2, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MS_PER_DAY', () => {
|
||||
it('FE-HOOK-DASHMODEL-009: is one day in milliseconds', () => {
|
||||
expect(MS_PER_DAY).toBe(24 * 60 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
const endedYesterday = { id: 1, start_date: '2026-08-20', end_date: '2026-08-24' }
|
||||
const startsToday = { id: 2, start_date: '2026-08-25', end_date: '2026-08-26' }
|
||||
const sorted = sortTrips([endedYesterday, startsToday] as never[])
|
||||
expect(sorted.map((t: { id: number }) => t.id)).toEqual([2, 1])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import type { Trip } from '../../types'
|
||||
import { localIsoDate } from '../../utils/localDate'
|
||||
|
||||
// The dashboard works with the canonical Trip shape returned by the list/get
|
||||
// endpoints (it already carries the computed day_count/place_count/is_owner/
|
||||
@@ -29,6 +30,15 @@ export interface UpcomingReservation {
|
||||
|
||||
export const MS_PER_DAY = 86400000
|
||||
|
||||
/**
|
||||
* Today as a local-calendar 'YYYY-MM-DD' — see utils/localDate for why this
|
||||
* must never come from toISOString(). Trip dates are wall-clock dates
|
||||
* (daysUntil below parses them as local midnight).
|
||||
*/
|
||||
export function localIsoToday(): string {
|
||||
return localIsoDate()
|
||||
}
|
||||
|
||||
export function daysUntil(dateStr: string | null | undefined): number | null {
|
||||
if (!dateStr) return null
|
||||
const today = new Date(); today.setHours(0, 0, 0, 0)
|
||||
@@ -37,7 +47,7 @@ export function daysUntil(dateStr: string | null | undefined): number | null {
|
||||
}
|
||||
|
||||
export function getTripStatus(trip: DashboardTrip): 'ongoing' | 'today' | 'tomorrow' | 'future' | 'past' | null {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const today = localIsoToday()
|
||||
if (trip.start_date && trip.end_date && trip.start_date <= today && trip.end_date >= today) return 'ongoing'
|
||||
const until = daysUntil(trip.start_date)
|
||||
if (until === null) return null
|
||||
@@ -48,7 +58,7 @@ export function getTripStatus(trip: DashboardTrip): 'ongoing' | 'today' | 'tomor
|
||||
}
|
||||
|
||||
export function sortTrips(trips: DashboardTrip[]): DashboardTrip[] {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const today = localIsoToday()
|
||||
const rank = (t: DashboardTrip) => {
|
||||
if (t.start_date && t.end_date && t.start_date <= today && t.end_date >= today) return 0
|
||||
if (t.start_date && t.start_date >= today) return 1
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAuthStore } from '../../store/authStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../../components/shared/Toast'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import { localIsoToday } from './dashboardModel'
|
||||
import type { TripCreateRequest } from '@trek/shared'
|
||||
import {
|
||||
type DashboardTrip,
|
||||
@@ -92,7 +93,7 @@ export function useDashboard() {
|
||||
loadTrips()
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const today = localIsoToday()
|
||||
// A trip the hero features on its own merit: one that is running, else the next
|
||||
// one coming up. Only that one is taken out of the grid below, so the same trip
|
||||
// isn't shown twice.
|
||||
|
||||
@@ -59,3 +59,33 @@ describe('computeJourneyLifecycle', () => {
|
||||
expect(computeJourneyLifecycle('published', null, TODAY)).toBe('live')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The midnight-window regression: "today" must be the LOCAL calendar date.
|
||||
* The old code derived it from toISOString() (the UTC date), so between local
|
||||
* midnight and the UTC rollover a journey running through yesterday still
|
||||
* showed 'live' and one starting today showed 'upcoming'. Pinned at 00:30
|
||||
* local — vacuous on a UTC machine, biting on any TZ ahead of UTC.
|
||||
*/
|
||||
describe('computeJourneyLifecycle — local today at the midnight window', () => {
|
||||
beforeAll(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date(2026, 7, 25, 0, 30)) // local 2026-08-25 00:30
|
||||
})
|
||||
afterAll(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('a journey spanning the local today is live', () => {
|
||||
expect(computeJourneyLifecycle('active', '2026-08-25', '2026-08-26')).toBe('live')
|
||||
})
|
||||
|
||||
it('a journey that ended yesterday (local) is completed', () => {
|
||||
expect(computeJourneyLifecycle('active', '2026-08-20', '2026-08-24')).toBe('completed')
|
||||
})
|
||||
|
||||
it('single boundaries follow the local date too', () => {
|
||||
expect(computeJourneyLifecycle('active', '2026-08-25', null)).toBe('live') // starts today → live
|
||||
expect(computeJourneyLifecycle('active', null, '2026-08-24')).toBe('completed') // ended yesterday
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { localIsoDate } from './localDate'
|
||||
|
||||
export type JourneyLifecycle = 'archived' | 'live' | 'upcoming' | 'completed' | 'draft'
|
||||
|
||||
export function computeJourneyLifecycle(
|
||||
@@ -8,7 +10,7 @@ export function computeJourneyLifecycle(
|
||||
if (status === 'archived') return 'archived'
|
||||
|
||||
if (tripDateMin && tripDateMax) {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const today = localIsoDate()
|
||||
if (tripDateMin <= today && today <= tripDateMax) return 'live'
|
||||
if (tripDateMin > today) return 'upcoming'
|
||||
return 'completed'
|
||||
@@ -20,11 +22,11 @@ export function computeJourneyLifecycle(
|
||||
|
||||
// Single boundary: only start or only end
|
||||
if (tripDateMin && !tripDateMax) {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const today = localIsoDate()
|
||||
return tripDateMin > today ? 'upcoming' : 'live'
|
||||
}
|
||||
if (!tripDateMin && tripDateMax) {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const today = localIsoDate()
|
||||
return tripDateMax < today ? 'completed' : 'live'
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { localIsoDate } from './localDate'
|
||||
|
||||
/**
|
||||
* "Today" must be the user's local calendar date. `toISOString()` is the UTC
|
||||
* date — between local midnight and the UTC rollover it is YESTERDAY for any
|
||||
* TZ ahead of UTC, which misclassified trips/journeys in that window. On a UTC
|
||||
* machine local == UTC, so the distinction is vacuous there; these tests pin
|
||||
* the clock at 00:30 local, where any positive offset separates the two.
|
||||
*/
|
||||
describe('localIsoDate', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date(2026, 7, 25, 0, 30)) // local 2026-08-25 00:30
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('formats the local calendar date, zero-padded', () => {
|
||||
expect(localIsoDate()).toBe('2026-08-25')
|
||||
expect(localIsoDate(new Date(2026, 0, 3, 12))).toBe('2026-01-03')
|
||||
})
|
||||
|
||||
it('defaults to now', () => {
|
||||
expect(localIsoDate()).toBe(localIsoDate(new Date()))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* The user's LOCAL calendar date as 'YYYY-MM-DD'.
|
||||
*
|
||||
* Never derive "today" from `toISOString().split('T')[0]` — that is the UTC
|
||||
* date, which disagrees with the wall clock between local midnight and the UTC
|
||||
* rollover (for a UTC+2 user, from 00:00 to 02:00 every night). Trip and
|
||||
* journey dates are wall-clock dates, so every "is this today/past/upcoming"
|
||||
* comparison must use this instead.
|
||||
*
|
||||
* (Converting a Date you deliberately built in UTC — `new Date(s + 'T00:00:00Z')`
|
||||
* round-tripped through date arithmetic — is a different, self-consistent
|
||||
* pattern and fine as it is.)
|
||||
*/
|
||||
export function localIsoDate(d: Date = new Date()): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
@@ -25,10 +25,13 @@ vi.mock('../../../../src/components/Plugins/TripCardBadges', () => ({
|
||||
useTripCardBadges: () => (tripId: number) => mocks.badges[tripId] ?? [],
|
||||
}));
|
||||
|
||||
// Local-calendar date string — NOT toISOString(), which is the UTC date and
|
||||
// disagrees with the badge logic's wall-clock classification between local
|
||||
// midnight and the UTC rollover (these tests flaked in exactly that window).
|
||||
const iso = (offsetDays: number): string => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + offsetDays);
|
||||
return d.toISOString().split('T')[0];
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
function buildTrip(over: Partial<DashboardTrip> = {}): DashboardTrip {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { localIsoDate } from '../../../../src/utils/localDate';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { mapsApi, weatherApi } from '../../../../src/api/client';
|
||||
import { useAddonStore } from '../../../../src/store/addonStore';
|
||||
@@ -326,8 +327,8 @@ describe('MJourneyEntrySheet full editor', () => {
|
||||
expect(screen.getByText('Mood')).toBeInTheDocument();
|
||||
expect(screen.getByText('Weather')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tags')).toBeInTheDocument();
|
||||
// An empty entry_date falls back to today, an empty gallery locks the picker.
|
||||
expect(dateField().value).toBe(new Date().toISOString().split('T')[0]);
|
||||
// An empty entry_date falls back to today (the LOCAL date), an empty gallery locks the picker.
|
||||
expect(dateField().value).toBe(localIsoDate());
|
||||
expect(timeField()).toHaveValue('');
|
||||
expect(screen.getByRole('button', { name: 'From Gallery' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Upload photos' })).toBeEnabled();
|
||||
|
||||
Reference in New Issue
Block a user