feat(journey): add mobile quick capture with location and weather

This commit is contained in:
yu
2026-07-23 20:22:36 +08:00
committed by Maurice
parent 60abcf6598
commit 617ffae351
9 changed files with 320 additions and 80 deletions
+1
View File
@@ -961,6 +961,7 @@ export const healthApi = {
export const weatherApi = {
get: (lat: number, lng: number, date: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')),
getCurrent: (lat: number, lng: number, lang?: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getCurrent')),
getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise<WeatherResult> => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getDetailed')),
}
@@ -355,6 +355,7 @@ export default function MJourneyDetail() {
<MJourneyEntrySheet
entry={editingEntry}
galleryPhotos={gallery}
quickCapture={editingEntry.id === 0}
readOnly={!canEditEntries}
onClose={() => setEditingEntry(null)}
onSave={async data => {
@@ -1,17 +1,17 @@
import { useRef, useState } from 'react'
import { Plus, Image, X, MapPin, Trash2, CheckCircle2, MinusCircle } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { Camera, Plus, Image, X, MapPin, Trash2, CheckCircle2, MinusCircle } from 'lucide-react'
import MSheet from '../../components/MSheet'
import MIconBtn from '../../components/MIconBtn'
import { useTranslation } from '../../../i18n'
import { useToast } from '../../../components/shared/Toast'
import { journeyApi, mapsApi } from '../../../api/client'
import { journeyApi, mapsApi, weatherApi } from '../../../api/client'
import { getApiErrorMessage } from '../../../types'
import { normalizeImageFiles } from '../../../utils/convertHeic'
import type { ResilientResult, UploadProgress } from '../../../utils/uploadQueue'
import type { JourneyEntry, JourneyPhoto, GalleryPhoto } from '../../../store/journeyStore'
import { photoUrl } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
import JournalBody from '../../../components/Journey/JournalBody'
import { MOBILE_MOODS, MOBILE_WEATHERS } from './mobileJourneyMeta'
import { journeyWeatherCategory, MOBILE_MOODS, MOBILE_WEATHERS } from './mobileJourneyMeta'
const PRO_COLOR = '#2FA37A'
const CON_COLOR = '#D6273B'
@@ -26,6 +26,7 @@ interface LocationResult {
interface MJourneyEntrySheetProps {
entry: JourneyEntry
galleryPhotos: GalleryPhoto[]
quickCapture?: boolean
readOnly?: boolean
onClose: () => void
onSave: (data: Record<string, unknown>) => Promise<number>
@@ -40,7 +41,7 @@ interface MJourneyEntrySheetProps {
* weather (6) and tags. Read-only for viewer contributors.
*/
export default function MJourneyEntrySheet({
entry, galleryPhotos, readOnly = false, onClose, onSave, onUploadPhotos, onDelete, onDone,
entry, galleryPhotos, quickCapture = false, readOnly = false, onClose, onSave, onUploadPhotos, onDelete, onDone,
}: MJourneyEntrySheetProps) {
const { t } = useTranslation()
const toast = useToast()
@@ -67,8 +68,49 @@ export default function MJourneyEntrySheet({
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
const [showGalleryPick, setShowGalleryPick] = useState(false)
const [saving, setSaving] = useState(false)
const [captureOnly, setCaptureOnly] = useState(quickCapture)
const [locating, setLocating] = useState(false)
const [locationError, setLocationError] = useState('')
const [uploadProgress, setUploadProgress] = useState<{ done: number; total: number } | null>(null)
const fileRef = useRef<HTMLInputElement>(null)
const cameraRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (!quickCapture || readOnly || entry.location_lat != null || entry.location_lng != null) return
if (!navigator.geolocation) {
setLocationError(t('common.error'))
return
}
let active = true
setLocating(true)
navigator.geolocation.getCurrentPosition(async position => {
const lat = position.coords.latitude
const lng = position.coords.longitude
if (!active) return
setLocationLat(lat)
setLocationLng(lng)
const [placeResult, weatherResult] = await Promise.allSettled([
mapsApi.reverse(lat, lng),
weatherApi.getCurrent(lat, lng, 'en'),
])
if (!active) return
if (placeResult.status === 'fulfilled') {
setLocationName(placeResult.value.name || placeResult.value.address || '')
}
if (weatherResult.status === 'fulfilled' && !weatherResult.value.error) {
setWeather(current => current || journeyWeatherCategory(weatherResult.value.main, weatherResult.value.description))
}
setLocating(false)
}, error => {
if (!active) return
setLocationError(error.message || t('common.error'))
setLocating(false)
}, { enableHighAccuracy: true, maximumAge: 60_000, timeout: 10_000 })
return () => { active = false }
}, [quickCapture, readOnly, entry.location_lat, entry.location_lng, entry.entry_date, t])
const isDirty =
title !== (entry.title || '') ||
@@ -85,7 +127,7 @@ export default function MJourneyEntrySheet({
pendingLinkIds.length > 0
const handleClose = () => {
if (!readOnly && isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return
if (!captureOnly && !readOnly && isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return
onClose()
}
@@ -193,7 +235,7 @@ export default function MJourneyEntrySheet({
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] py-3">
{readOnly ? (
{!captureOnly && (readOnly ? (
<div className="pb-[10px] pt-1 text-[1.25rem] font-extrabold">{title || t('journey.editor.titlePlaceholder')}</div>
) : (
<input
@@ -202,15 +244,16 @@ export default function MJourneyEntrySheet({
placeholder={t('journey.editor.titlePlaceholder')}
className="w-full bg-transparent pb-[10px] pt-1 text-[1.25rem] font-extrabold text-m-ink outline-none placeholder:text-m-faint"
/>
)}
))}
{!readOnly && (
<>
<input ref={cameraRef} type="file" accept="image/*" capture="environment" className="hidden" onChange={handleFileChange} onClick={e => { (e.target as HTMLInputElement).value = '' }} />
<input ref={fileRef} type="file" accept="image/*" multiple className="hidden" onChange={handleFileChange} onClick={e => { (e.target as HTMLInputElement).value = '' }} />
<div className="flex gap-2">
<button
type="button"
onClick={() => fileRef.current?.click()}
onClick={() => (captureOnly ? cameraRef : fileRef).current?.click()}
disabled={saving}
className="flex flex-1 items-center justify-center gap-[6px] rounded-[14px] border-[1.5px] border-dashed border-[color:var(--m-rowbr)] p-3 text-[0.75rem] font-semibold text-m-muted disabled:opacity-50"
>
@@ -221,27 +264,27 @@ export default function MJourneyEntrySheet({
</>
) : (
<>
<Plus size={14} strokeWidth={2.2} />
{t('journey.editor.uploadPhotos')}
{captureOnly ? <Camera size={14} strokeWidth={2.2} /> : <Plus size={14} strokeWidth={2.2} />}
{captureOnly ? t('journey.photo.add') : t('journey.editor.uploadPhotos')}
</>
)}
</button>
<button
type="button"
onClick={() => setShowGalleryPick(v => !v)}
disabled={galleryPhotos.length === 0}
onClick={() => captureOnly ? fileRef.current?.click() : setShowGalleryPick(v => !v)}
disabled={!captureOnly && galleryPhotos.length === 0}
className={`flex flex-1 items-center justify-center gap-[6px] rounded-[14px] border-[1.5px] p-3 text-[0.75rem] font-semibold disabled:opacity-40 ${
showGalleryPick
!captureOnly && showGalleryPick
? 'border-[color:var(--m-act)] text-m-ink'
: 'border-dashed border-[color:var(--m-rowbr)] text-m-muted'
}`}
>
<Image size={14} strokeWidth={2} />
{t('journey.editor.fromGallery')}
{captureOnly ? t('journey.share.gallery') : t('journey.editor.fromGallery')}
</button>
</div>
{showGalleryPick && (
{!captureOnly && showGalleryPick && (
<div className="mt-2 max-h-[160px] overflow-y-auto rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-2">
<div className="grid grid-cols-5 gap-[6px]">
{availableGalleryPhotos.map(gp => (
@@ -333,24 +376,25 @@ export default function MJourneyEntrySheet({
</div>
)}
{readOnly ? (
story && (
<div className="mt-[10px] font-geist text-[0.8125rem] leading-[1.5] text-m-ink">
<JournalBody text={story} />
</div>
)
) : (
<textarea
rows={3}
value={story}
onChange={e => setStory(e.target.value)}
placeholder={t('journey.editor.writeStory')}
className={`mt-[10px] w-full resize-none px-[14px] py-3 font-geist text-[0.8125rem] leading-[1.5] text-m-ink outline-none placeholder:text-m-faint ${fieldShell} rounded-[14px]`}
/>
)}
{!captureOnly && <>
{readOnly ? (
story && (
<div className="mt-[10px] font-geist text-[0.8125rem] leading-[1.5] text-m-ink">
<JournalBody text={story} />
</div>
)
) : (
<textarea
rows={3}
value={story}
onChange={e => setStory(e.target.value)}
placeholder={t('journey.editor.writeStory')}
className={`mt-[10px] w-full resize-none px-[14px] py-3 font-geist text-[0.8125rem] leading-[1.5] text-m-ink outline-none placeholder:text-m-faint ${fieldShell} rounded-[14px]`}
/>
)}
{/* Pros & Cons */}
{(!readOnly || pros.length > 0 || cons.length > 0) && (
{/* Pros & Cons */}
{(!readOnly || pros.length > 0 || cons.length > 0) && (
<div className="mt-3 rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-[13px]">
<div className={`${eyebrow} mb-2`}>{t('journey.editor.prosCons')}</div>
<div className="flex gap-[10px]">
@@ -422,29 +466,34 @@ export default function MJourneyEntrySheet({
</div>
</div>
</div>
)}
)}
</>}
{/* Date + Time */}
<div className="mt-3 flex gap-2">
<div className="flex-1">
<div className="min-w-0 flex-1">
<div className={`${eyebrow} mb-[5px]`}>{t('journey.editor.date')}</div>
<input
type="date"
value={entryDate}
disabled={readOnly}
onChange={e => setEntryDate(e.target.value)}
className={`w-full px-3 py-[10px] text-[0.78125rem] font-semibold text-m-ink outline-none [font-variant-numeric:tabular-nums] ${fieldShell}`}
/>
<div className={`${fieldShell} overflow-hidden`}>
<input
type="date"
value={entryDate}
disabled={readOnly}
onChange={e => setEntryDate(e.target.value)}
className="block min-w-0 w-full box-border border-0 bg-transparent px-3 py-[10px] text-center text-[0.78125rem] font-semibold text-m-ink outline-none [font-variant-numeric:tabular-nums]"
/>
</div>
</div>
<div className="flex-1">
<div className="min-w-0 flex-1">
<div className={`${eyebrow} mb-[5px]`}>{t('mobileJourney.time')}</div>
<input
type="time"
value={entryTime}
disabled={readOnly}
onChange={e => setEntryTime(e.target.value)}
className={`w-full px-3 py-[10px] text-[0.78125rem] font-semibold text-m-ink outline-none [font-variant-numeric:tabular-nums] ${fieldShell}`}
/>
<div className={`${fieldShell} overflow-hidden`}>
<input
type="time"
value={entryTime}
disabled={readOnly}
onChange={e => setEntryTime(e.target.value)}
className="block min-w-0 w-full box-border border-0 bg-transparent px-3 py-[10px] text-center text-[0.78125rem] font-semibold text-m-ink outline-none [font-variant-numeric:tabular-nums]"
/>
</div>
</div>
</div>
@@ -487,30 +536,34 @@ export default function MJourneyEntrySheet({
))}
</div>
)}
{locating && <div className="mt-[5px] font-geist text-[0.65625rem] text-m-muted">{t('common.loading')}</div>}
{locationError && <div className="mt-[5px] font-geist text-[0.65625rem] text-[color:var(--m-st-danger)]">{locationError}</div>}
</div>
{/* Mood */}
<div className={`${eyebrow} mb-[6px] mt-3`}>{t('journey.editor.mood')}</div>
<div className="flex flex-wrap gap-[6px]">
{MOBILE_MOODS.map(m => {
const active = mood === m.id
return (
<button
key={m.id}
type="button"
disabled={readOnly}
onClick={() => setMood(active ? '' : m.id)}
className={`flex items-center gap-[5px] rounded-full border px-3 py-[7px] text-[0.71875rem] font-semibold ${
active ? '' : 'border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-muted'
}`}
style={active ? { background: `${m.color}24`, color: m.color, borderColor: `${m.color}4D` } : undefined}
>
<m.icon size={13} strokeWidth={2.2} />
{t(m.labelKey)}
</button>
)
})}
</div>
{!captureOnly && <>
<div className={`${eyebrow} mb-[6px] mt-3`}>{t('journey.editor.mood')}</div>
<div className="flex flex-wrap gap-[6px]">
{MOBILE_MOODS.map(m => {
const active = mood === m.id
return (
<button
key={m.id}
type="button"
disabled={readOnly}
onClick={() => setMood(active ? '' : m.id)}
className={`flex items-center gap-[5px] rounded-full border px-3 py-[7px] text-[0.71875rem] font-semibold ${
active ? '' : 'border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-muted'
}`}
style={active ? { background: `${m.color}24`, color: m.color, borderColor: `${m.color}4D` } : undefined}
>
<m.icon size={13} strokeWidth={2.2} />
{t(m.labelKey)}
</button>
)
})}
</div>
</>}
{/* Weather */}
<div className={`${eyebrow} mb-[6px] mt-3`}>{t('journey.editor.weather')}</div>
@@ -537,7 +590,7 @@ export default function MJourneyEntrySheet({
</div>
{/* Tags */}
{(!readOnly || tags.length > 0) && (
{!captureOnly && (!readOnly || tags.length > 0) && (
<>
<div className={`${eyebrow} mb-[6px] mt-3`}>{t('mobileJourney.tags')}</div>
<div className={`flex flex-wrap items-center gap-[6px] px-3 py-2 ${fieldShell} rounded-[14px]`}>
@@ -582,6 +635,15 @@ export default function MJourneyEntrySheet({
{t('common.delete')}
</button>
)}
{!readOnly && captureOnly && (
<button
type="button"
onClick={() => setCaptureOnly(false)}
className="rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-4 py-[9px] text-[0.78125rem] font-semibold"
>
{t('collections.addDetails')}
</button>
)}
<button
type="button"
onClick={handleClose}
@@ -45,6 +45,17 @@ export function weatherMeta(id: string | null | undefined): WeatherMeta | undefi
return id ? MOBILE_WEATHERS.find(w => w.id === id) : undefined
}
export function journeyWeatherCategory(main: string, description: string): string {
const normalizedMain = main.toLowerCase()
const normalizedDescription = description.toLowerCase()
if (normalizedMain === 'clear') return 'sunny'
if (normalizedMain === 'thunderstorm') return 'stormy'
if (normalizedMain === 'snow') return 'cold'
if (normalizedMain === 'rain' || normalizedMain === 'drizzle') return 'rainy'
if (normalizedDescription.includes('partly') || normalizedDescription.includes('teilweise')) return 'partly'
return 'cloudy'
}
/** Journey cover_image is stored relative — prefix /uploads/ unless it already is. */
export function journeyCoverSrc(coverImage: string | null | undefined): string | null {
if (!coverImage) return null
+3 -6
View File
@@ -15,10 +15,9 @@ import {
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
import MobileEntryView from '../components/Journey/MobileEntryView'
import { useJourneyStore } from '../store/journeyStore'
import type { JourneyEntry } from '../store/journeyStore'
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
import { useJourneyDetail } from './journeyDetail/useJourneyDetail'
import { pickGradient, groupByDate, formatDate, photoUrl } from './journeyDetail/JourneyDetailPage.helpers'
import { createDraftJourneyEntry, pickGradient, groupByDate, formatDate, photoUrl } from './journeyDetail/JourneyDetailPage.helpers'
import { EntryCard, SkeletonCard, CheckinCard } from '../components/Journey/JourneyDetailPageEntryCard'
import { GalleryView } from '../components/Journey/JourneyDetailPageGalleryView'
import { EntryEditor } from '../components/Journey/JourneyDetailPageEntryEditor'
@@ -92,8 +91,7 @@ function JourneyDetailPageDesktop() {
readOnly={!canEditEntries}
onEntryClick={(entry) => setViewingEntry(entry)}
onAddEntry={canEditEntries ? () => {
const today = new Date().toISOString().split('T')[0]
setEditingEntry({ id: 0, journey_id: current.id, author_id: 0, type: 'entry', entry_date: today, visibility: 'private', sort_order: 0, photos: [], created_at: 0, updated_at: 0 } as JourneyEntry)
setEditingEntry(createDraftJourneyEntry(current.id))
} : undefined}
/>
)}
@@ -303,8 +301,7 @@ function JourneyDetailPageDesktop() {
{canEditEntries && view === 'timeline' && (
<button
onClick={() => {
const today = new Date().toISOString().split('T')[0]
setEditingEntry({ id: 0, journey_id: current.id, author_id: 0, type: 'entry', entry_date: today, visibility: 'private', sort_order: 0, photos: [], created_at: 0, updated_at: 0 } as JourneyEntry)
setEditingEntry(createDraftJourneyEntry(current.id))
}}
className="inline-flex items-center gap-1.5 h-9 px-4 rounded-full text-[13px] font-semibold transition-transform hover:-translate-y-0.5"
style={{ background: 'var(--vg-ink)', color: 'var(--vg-bg)' }}
@@ -15,6 +15,24 @@ export function groupByDate(entries: JourneyEntry[]): Map<string, JourneyEntry[]
return groups
}
export function createDraftJourneyEntry(journeyId: number, now = new Date()): JourneyEntry {
const entryDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
const entryTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
return {
id: 0,
journey_id: journeyId,
author_id: 0,
type: 'entry',
entry_date: entryDate,
entry_time: entryTime,
visibility: 'private',
sort_order: 0,
photos: [],
created_at: 0,
updated_at: 0,
}
}
export function formatDate(d: string, locale?: string): { weekday: string; month: string; day: number } {
const date = new Date(d + 'T00:00:00')
// Pass the app's selected locale so weekday/month follow the UI language
@@ -8,6 +8,7 @@ import type { JourneyMapAutoHandle as JourneyMapHandle } from '../../components/
import { useToast } from '../../components/shared/Toast'
import { useIsMobile } from '../../hooks/useIsMobile'
import type { JourneyEntry } from '../../store/journeyStore'
import { createDraftJourneyEntry } from './JourneyDetailPage.helpers'
/**
* Journey detail page logic — owns the journey load + WebSocket live sync, the
@@ -48,8 +49,7 @@ export function useJourneyDetail() {
// The bottom-nav "+" starts a new entry via ?create=entry.
useEffect(() => {
if (searchParams.get('create') === 'entry' && current && canEditEntries) {
const today = new Date().toISOString().slice(0, 10)
setEditingEntry({ id: 0, journey_id: current.id, author_id: 0, type: 'entry', entry_date: today, visibility: 'private', sort_order: 0, photos: [], created_at: 0, updated_at: 0 } as JourneyEntry)
setEditingEntry(createDraftJourneyEntry(current.id))
setSearchParams(p => { p.delete('create'); return p }, { replace: true })
}
}, [searchParams, current, canEditEntries])
@@ -0,0 +1,134 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { mapsApi, weatherApi } from '../../../../src/api/client';
import MJourneyEntrySheet from '../../../../src/mobile/screens/journey/MJourneyEntrySheet';
import type { JourneyEntry } from '../../../../src/store/journeyStore';
import { fireEvent, render, screen, waitFor } from '../../../helpers/render';
const entry: JourneyEntry = {
id: 0,
journey_id: 7,
author_id: 0,
type: 'entry',
entry_date: '2026-07-22',
entry_time: '09:07',
visibility: 'private',
sort_order: 0,
photos: [],
created_at: 0,
updated_at: 0,
};
function setup() {
const props = {
entry,
galleryPhotos: [],
quickCapture: true,
onClose: vi.fn(),
onSave: vi.fn().mockResolvedValue(42),
onUploadPhotos: vi.fn().mockResolvedValue({ succeeded: [], failed: [] }),
onDone: vi.fn(),
};
const view = render(<MJourneyEntrySheet {...props} />);
return { ...view, props };
}
describe('MJourneyEntrySheet quick capture', () => {
beforeEach(() => {
vi.restoreAllMocks();
Object.defineProperty(navigator, 'geolocation', {
configurable: true,
value: {
getCurrentPosition: vi.fn((success) => success({ coords: { latitude: 1.3521, longitude: 103.8198 } })),
},
});
});
it('captures GPS, reverse-geocodes it, detects an editable weather category, and saves', async () => {
vi.spyOn(mapsApi, 'reverse').mockResolvedValue({ name: 'Singapore', address: 'Singapore' });
vi.spyOn(weatherApi, 'getCurrent').mockResolvedValue({
temp: 30,
main: 'Rain',
description: 'Rain',
type: 'current',
});
const { props } = setup();
await waitFor(() => expect(mapsApi.reverse).toHaveBeenCalledWith(1.3521, 103.8198));
expect(weatherApi.getCurrent).toHaveBeenCalledWith(1.3521, 103.8198, 'en');
await waitFor(() => expect(screen.getByDisplayValue('Singapore')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() =>
expect(props.onSave).toHaveBeenCalledWith(
expect.objectContaining({
entry_date: '2026-07-22',
entry_time: '09:07',
location_name: 'Singapore',
location_lat: 1.3521,
location_lng: 103.8198,
weather: 'rainy',
})
)
);
expect(props.onDone).toHaveBeenCalled();
});
it('offers native camera and gallery inputs', () => {
vi.spyOn(mapsApi, 'reverse').mockResolvedValue({ name: null, address: null });
vi.spyOn(weatherApi, 'getCurrent').mockResolvedValue({
temp: 0,
main: '',
description: '',
type: '',
error: 'unavailable',
});
setup();
expect(document.querySelector('input[type="file"][capture="environment"]')).toBeInTheDocument();
expect(document.querySelector('input[type="file"][multiple]')).toBeInTheDocument();
return waitFor(() => expect(mapsApi.reverse).toHaveBeenCalled());
});
it('can expand into the full editor before saving', async () => {
vi.spyOn(mapsApi, 'reverse').mockResolvedValue({ name: null, address: null });
vi.spyOn(weatherApi, 'getCurrent').mockResolvedValue({
temp: 0,
main: '',
description: '',
type: '',
error: 'unavailable',
});
setup();
expect(screen.queryByPlaceholderText('Give this moment a name...')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Add details' }));
expect(screen.getByPlaceholderText('Give this moment a name...')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Write your story...')).toBeInTheDocument();
await waitFor(() => expect(mapsApi.reverse).toHaveBeenCalled());
});
it('still saves when location permission is denied', async () => {
Object.defineProperty(navigator, 'geolocation', {
configurable: true,
value: {
getCurrentPosition: vi.fn((_success, failure) => failure({ message: 'Permission denied' })),
},
});
const { props } = setup();
expect(await screen.findByText('Permission denied')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() =>
expect(props.onSave).toHaveBeenCalledWith(
expect.objectContaining({
entry_date: '2026-07-22',
entry_time: '09:07',
location_lat: null,
location_lng: null,
})
)
);
expect(props.onDone).toHaveBeenCalled();
});
});
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { journeyWeatherCategory } from '../../../../src/mobile/screens/journey/mobileJourneyMeta';
describe('journeyWeatherCategory', () => {
it.each([
['Clear', 'Clear sky', 'sunny'],
['Clouds', 'Partly cloudy', 'partly'],
['Clouds', 'Teilweise bewolkt', 'partly'],
['Clouds', 'Overcast', 'cloudy'],
['Rain', 'Rain', 'rainy'],
['Thunderstorm', 'Thunderstorm', 'stormy'],
['Snow', 'Snowfall', 'cold'],
])('maps %s / %s to the existing %s category', (main, description, expected) => {
expect(journeyWeatherCategory(main, description)).toBe(expected);
});
});