mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(journey): add mobile quick capture with location and weather
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user