mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
feat(tours): the trip's tour totals by type above the tour list (STA-01)
The Tours tab's list of the trip's tours gains the filter the plan's tour list has, and above the list a card with what the tours shown add up to: how many, how many are on a day and how many not, then per type distance, ascent and descent, and the three kinds of time apart, the router's for tours planned in TREK, the one an import brought, and the planned one with breaks. Nothing is added up across types. The filter narrows the list and the totals alike. Each tour counts once however many days it is on; the list is one row a tour. A value no tour has is left out rather than shown as zero, and a sum some tours lack is shown as at least that much, its info dot saying for how many of how many it is known. Paddling sums no heights: on the water a climb says nothing, and a planned paddle tour's heights would be the banks'. The card folds to its counts, remembered in this browser. The empty state for a filter that leaves nothing is shared by both lists now.
This commit is contained in:
@@ -177,3 +177,26 @@ export default function TourListFilterMenu({ filters }: { filters: TourListFilte
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** What a list says when its filters leave nothing: so, and a way to clear them. */
|
||||
export function TourFilterEmpty({ filters }: { filters: TourListFilters }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="mx-1.5 mt-2 flex flex-col items-center gap-2 rounded-[12px] bg-surface-card px-4 py-5 text-center shadow-sm ring-1 ring-inset ring-edge-faint"
|
||||
>
|
||||
<p className="m-0 text-content-muted" style={fs(12.5, 'body')}>
|
||||
{t('tours.filter.empty')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={filters.reset}
|
||||
className="rounded-full bg-surface-secondary px-3 py-1 font-semibold text-content-secondary transition-colors hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
style={fs(11.5, 'body')}
|
||||
>
|
||||
{t('tours.filter.reset')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { TourListItem, TourTypeKey } from '@trek/shared';
|
||||
import { ChartColumnBig, ChevronDown, Mountain, Route, Waves } from 'lucide-react';
|
||||
import { useMemo, useState, type ComponentType } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { formatDistance, formatElevation } from '../../utils/units';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import { InfoDot, RailCard, RailFacts, type TourMetricField } from './tourParts';
|
||||
import { formatPlannedTourDuration } from './tourPresentation';
|
||||
import { tourScopeSummary, type ScopeSum, type TourTypeScope } from './tourScope';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
||||
|
||||
const TYPE_ICON: Partial<Record<TourTypeKey, ComponentType<{ size?: number; strokeWidth?: number }>>> = {
|
||||
hike: Mountain,
|
||||
kayak: Waves,
|
||||
};
|
||||
|
||||
/** Whether the card is folded, kept per browser like other view conveniences; a blocked storage keeps it open. */
|
||||
const FOLDED_KEY = 'trek.tours.scopeSummary.folded';
|
||||
function readFolded(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(FOLDED_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function storeFolded(folded: boolean): void {
|
||||
try {
|
||||
localStorage.setItem(FOLDED_KEY, folded ? '1' : '0');
|
||||
} catch {
|
||||
// Private mode or blocked storage: the card simply opens again next time.
|
||||
}
|
||||
}
|
||||
|
||||
/** A count as a white badge of the card: the number, then what it counts. */
|
||||
function CountBadge({ value, label, tone = 'neutral' }: { value: number; label: string; tone?: 'neutral' | 'faint' }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex flex-none items-center gap-1 rounded-full bg-surface-card px-2 py-0.5 shadow-sm ring-1 ring-inset ring-edge-faint ${tone === 'faint' ? 'text-content-muted' : 'text-content-secondary'}`}
|
||||
style={fs(11, 'body')}
|
||||
>
|
||||
<span className="font-geist font-semibold tabular-nums text-content">{value}</span>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A sum as a fact: nothing when no tour has the value, else the sum, marked as
|
||||
* at least that much with the reason in its info dot when some tours lack it.
|
||||
*/
|
||||
function sumFact(
|
||||
label: string,
|
||||
sum: ScopeSum,
|
||||
format: (value: number) => string,
|
||||
t: Translate
|
||||
): TourMetricField | null {
|
||||
if (sum.value === null) return null;
|
||||
const partial = sum.known < sum.of;
|
||||
return {
|
||||
label,
|
||||
value: partial ? `≥ ${format(sum.value)}` : format(sum.value),
|
||||
info: partial
|
||||
? {
|
||||
label: t('tours.scope.partial'),
|
||||
description: t('tours.scope.partialBody', { known: sum.known, of: sum.of }),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function typeFacts(scope: TourTypeScope, unit: Parameters<typeof formatDistance>[1], t: Translate): TourMetricField[] {
|
||||
const distance = (km: number) => formatDistance(km, unit);
|
||||
const height = (m: number) => formatElevation(m, unit);
|
||||
return [
|
||||
sumFact(t('tours.detail.distance'), scope.distanceKm, distance, t),
|
||||
sumFact(t('tours.detail.ascent'), scope.ascentM, height, t),
|
||||
sumFact(t('tours.detail.descent'), scope.descentM, height, t),
|
||||
sumFact(t('tours.scope.routed'), scope.routedMinutes, formatPlannedTourDuration, t),
|
||||
sumFact(t('tours.scope.imported'), scope.importedMinutes, formatPlannedTourDuration, t),
|
||||
sumFact(t('tours.scope.planned'), scope.plannedMinutes, formatPlannedTourDuration, t),
|
||||
].filter((fact): fact is TourMetricField => fact !== null);
|
||||
}
|
||||
|
||||
/** One tour type: its name with how many tours and how many are on a day, then its sums. */
|
||||
function TypeScope({ scope }: { scope: TourTypeScope }) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
|
||||
const Icon = TYPE_ICON[scope.type] ?? Route;
|
||||
const facts = typeFacts(scope, unit, t);
|
||||
return (
|
||||
<section
|
||||
aria-label={t(`tourTypes.${scope.type}`)}
|
||||
className="flex flex-col gap-2.5"
|
||||
data-testid={`tour-scope-${scope.type}`}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="grid h-4 w-4 flex-none place-items-center text-content-muted">
|
||||
<Icon size={13} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(12, 'body')}>
|
||||
{t(`tourTypes.${scope.type}`)}
|
||||
</span>
|
||||
<span className="font-geist tabular-nums text-content-faint" style={fs(11)}>
|
||||
{scope.count}
|
||||
</span>
|
||||
</div>
|
||||
{facts.length > 0 ? (
|
||||
<RailFacts facts={facts} />
|
||||
) : (
|
||||
<p className="m-0 text-content-faint" style={fs(11.5, 'body')}>
|
||||
{t('tours.scope.noMeasures')}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* STA-01: what the trip's tours add up to, above their list, by type: how many,
|
||||
* how many are on a day, distance, ascent and descent, and the three kinds of
|
||||
* time apart (the router's, an import's, the planned one with breaks), never
|
||||
* added up across types. It reads the list as filtered, so a type chosen in the
|
||||
* filter narrows both. A value no tour has is left out rather than shown as
|
||||
* zero; a sum some tours lack is marked as at least that much. Folding it is
|
||||
* remembered in this browser.
|
||||
*/
|
||||
export function TourScopeSummary({ tours }: { tours: readonly TourListItem[] }) {
|
||||
const { t } = useTranslation();
|
||||
const [folded, setFolded] = useState(readFolded);
|
||||
const scope = useMemo(() => tourScopeSummary(tours), [tours]);
|
||||
if (scope.count === 0) return null;
|
||||
const toggle = () => {
|
||||
setFolded((value) => {
|
||||
storeFolded(!value);
|
||||
return !value;
|
||||
});
|
||||
};
|
||||
const toggleLabel = folded ? t('common.expand') : t('common.collapse');
|
||||
return (
|
||||
<RailCard
|
||||
icon={<ChartColumnBig size={14} strokeWidth={2} />}
|
||||
title={t('tours.scope.title')}
|
||||
aside={
|
||||
<span className="flex flex-none items-center gap-0.5">
|
||||
<InfoDot label={t('tours.scope.title')} description={t('tours.scope.info')} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-expanded={!folded}
|
||||
aria-label={toggleLabel}
|
||||
data-testid="tour-scope-toggle"
|
||||
className="grid h-6 w-6 place-items-center rounded-full text-content-faint transition-colors hover:bg-surface-hover hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
>
|
||||
<ChevronDown size={14} strokeWidth={2.2} className={`transition-transform ${folded ? '' : 'rotate-180'}`} />
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-1.5" data-testid="tour-scope-counts">
|
||||
<CountBadge value={scope.count} label={t('tours.mode.tours')} />
|
||||
<CountBadge value={scope.onDays} label={t('places.planned')} tone="faint" />
|
||||
<CountBadge value={scope.count - scope.onDays} label={t('places.unplanned')} tone="faint" />
|
||||
</div>
|
||||
{!folded && (
|
||||
<div className="mt-3.5 flex flex-col gap-4 border-t border-edge-faint pt-3.5">
|
||||
{scope.types.map((typeScope) => (
|
||||
<TypeScope key={typeScope.type} scope={typeScope} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</RailCard>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import { TourDayMenu } from './tourParts'
|
||||
import TourListFilterMenu from './TourListFilterMenu'
|
||||
import TourListFilterMenu, { TourFilterEmpty } from './TourListFilterMenu'
|
||||
import { useTourListFilters } from './useTourListFilters'
|
||||
import TourGpxPreviewDialog from './TourGpxPreviewDialog'
|
||||
import { useTourGpxImport } from './useTourGpxImport'
|
||||
@@ -97,14 +97,7 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel
|
||||
|
||||
<div className="trek-stagger min-h-0 flex-1 overflow-y-auto px-2 pb-2 pt-1.5">
|
||||
{!loading && filters.emptyByFilter ? (
|
||||
<div role="status" className="mx-1.5 mt-2 flex flex-col items-center gap-2 rounded-[12px] bg-surface-card px-4 py-5 text-center shadow-sm ring-1 ring-inset ring-edge-faint">
|
||||
<p className="m-0 text-content-muted" style={fs(12.5, 'body')}>{t('tours.filter.empty')}</p>
|
||||
<button type="button" onClick={filters.reset}
|
||||
className="rounded-full bg-surface-secondary px-3 py-1 font-semibold text-content-secondary transition-colors hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
style={fs(11.5, 'body')}>
|
||||
{t('tours.filter.reset')}
|
||||
</button>
|
||||
</div>
|
||||
<TourFilterEmpty filters={filters} />
|
||||
) : !loading && filters.filtered.length === 0 ? (
|
||||
<EmptyState scene="tours" size={92} fill surface="var(--bg-secondary)" title={t('tours.empty.title')}
|
||||
action={<p className="m-0 max-w-[260px] text-content-muted" style={fs(12, 'body')}>{t('tours.empty.body')}</p>} />
|
||||
|
||||
@@ -151,8 +151,9 @@ describe('TourPlannerToursRail', () => {
|
||||
|
||||
expect(screen.getByRole('complementary', { name: 'Tours of this trip' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('option', { name: /Saved ridge walk/i })).toHaveTextContent('4 km')
|
||||
expect(screen.getByText('Ascent')).toBeInTheDocument()
|
||||
expect(screen.getByText('50 m')).toBeInTheDocument()
|
||||
const overview = screen.getByTestId('tour-planner-overview')
|
||||
expect(within(overview).getByText('Ascent')).toBeInTheDocument()
|
||||
expect(within(overview).getByText('50 m')).toBeInTheDocument()
|
||||
expect(document.querySelector('svg[viewBox="0 0 440 100"]')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Routing is planning assistance, not a safety guarantee.')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -40,6 +40,9 @@ import { type AssignTourToDay, useTourDayAssignment } from '../useTourDayAssignm
|
||||
import WandererMark from '../wanderer/WandererMark'
|
||||
import { TourGpxExportBarButton } from '../TourGpxExportButton'
|
||||
import { TourRemoteChangeNotice, TourRevisionConflictDialog } from './TourRevisionNotice'
|
||||
import TourListFilterMenu, { TourFilterEmpty } from '../TourListFilterMenu'
|
||||
import { TourScopeSummary } from '../TourScopeSummary'
|
||||
import { useTourListFilters } from '../useTourListFilters'
|
||||
|
||||
const STATUS_KEY: Partial<Record<TourPlannerStatus, string>> = {
|
||||
dirty: 'tours.planner.status.dirty',
|
||||
@@ -645,6 +648,8 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
|
||||
const distanceUnit = useSettingsStore(state => state.settings.distance_unit)
|
||||
const [pendingTour, setPendingTour] = useState<TourListItem | null>(null)
|
||||
const dayAssignment = useTourDayAssignment({ canAssign, onAssignToDay })
|
||||
// STA-01: one filter for the list and the summary above it, the same as the plan's tour list.
|
||||
const filters = useTourListFilters(tours)
|
||||
const isSaving = planner.isSaving
|
||||
const hasRoute = (planner.mode.type === 'new-draft' || planner.mode.type === 'edit-saved') && planner.route !== null
|
||||
// A paddle tour's heights are read off the banks, not the water: they stay unknown.
|
||||
@@ -723,17 +728,21 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
|
||||
) : tours.length === 0 ? (
|
||||
<p className="m-0 py-6 text-center text-content-faint" style={fs(12, 'body')}>{t('common.loading')}</p>
|
||||
) : (
|
||||
<>
|
||||
<TourListFilterMenu filters={filters} />
|
||||
<TourScopeSummary tours={filters.filtered} />
|
||||
{filters.emptyByFilter ? <TourFilterEmpty filters={filters} /> : (
|
||||
<TourListCard
|
||||
icon={<Mountain size={14} strokeWidth={2} />}
|
||||
title={t('tours.mode.tours')}
|
||||
aside={
|
||||
<span className="flex-none tabular-nums text-content-faint" style={fs(11.5, 'body')}>
|
||||
{tours.length}
|
||||
{filters.filtered.length}
|
||||
</span>
|
||||
}
|
||||
label={t('tours.planner.tripTours')}
|
||||
>
|
||||
{tours.map(tour => (
|
||||
{filters.filtered.map(tour => (
|
||||
<TourListRow
|
||||
key={tour.place_id}
|
||||
tour={tour}
|
||||
@@ -769,6 +778,8 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
|
||||
/>
|
||||
))}
|
||||
</TourListCard>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { fireEvent, render, screen, within } from '../../../tests/helpers/render';
|
||||
import { TourScopeSummary } from './TourScopeSummary';
|
||||
import { tourScopeSummary } from './tourScope';
|
||||
|
||||
function tour(over: Partial<TourListItem> & Pick<TourListItem, 'place_id'>): TourListItem {
|
||||
return {
|
||||
tour_type: 'hike',
|
||||
name: `Tour ${over.place_id}`,
|
||||
distance: null,
|
||||
elevation_gain: null,
|
||||
elevation_loss: null,
|
||||
duration: null,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: null,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: false,
|
||||
caution: false,
|
||||
has_waypoints: true,
|
||||
origin: 'trek',
|
||||
...over,
|
||||
} as TourListItem;
|
||||
}
|
||||
|
||||
const HIKE_A = tour({
|
||||
place_id: 1,
|
||||
distance: 10,
|
||||
elevation_gain: 600,
|
||||
elevation_loss: 500,
|
||||
duration: 200,
|
||||
planned: true,
|
||||
});
|
||||
const HIKE_B = tour({
|
||||
place_id: 2,
|
||||
distance: 5.5,
|
||||
elevation_gain: null,
|
||||
elevation_loss: 100,
|
||||
duration: 100,
|
||||
break_additional_minutes: 30,
|
||||
});
|
||||
const GPX_HIKE = tour({
|
||||
place_id: 3,
|
||||
distance: 8,
|
||||
elevation_gain: 300,
|
||||
elevation_loss: 300,
|
||||
duration: 150,
|
||||
has_waypoints: false,
|
||||
origin: 'gpx',
|
||||
});
|
||||
const PADDLE = tour({
|
||||
place_id: 4,
|
||||
tour_type: 'kayak',
|
||||
distance: 12,
|
||||
elevation_gain: 40,
|
||||
elevation_loss: 40,
|
||||
duration: 180,
|
||||
planned: true,
|
||||
});
|
||||
|
||||
describe('tourScopeSummary (STA-01)', () => {
|
||||
it('counts each tour once, by type, the most tours first, with how many are on a day', () => {
|
||||
const scope = tourScopeSummary([HIKE_A, PADDLE, HIKE_B, GPX_HIKE, HIKE_A]);
|
||||
expect(scope.count).toBe(4);
|
||||
expect(scope.onDays).toBe(2);
|
||||
expect(scope.types.map((type) => [type.type, type.count, type.onDays])).toEqual([
|
||||
['hike', 3, 1],
|
||||
['kayak', 1, 1],
|
||||
]);
|
||||
});
|
||||
|
||||
it('sums what is known and says how much of the whole that is, never a zero for unknown', () => {
|
||||
const [hike] = tourScopeSummary([HIKE_A, HIKE_B, GPX_HIKE]).types;
|
||||
expect(hike.distanceKm).toEqual({ value: 23.5, known: 3, of: 3 });
|
||||
expect(hike.ascentM).toEqual({ value: 900, known: 2, of: 3 });
|
||||
const [none] = tourScopeSummary([tour({ place_id: 9 })]).types;
|
||||
expect(none.distanceKm).toEqual({ value: null, known: 0, of: 1 });
|
||||
});
|
||||
|
||||
it('keeps the routed, the imported and the planned time apart', () => {
|
||||
const [hike] = tourScopeSummary([HIKE_A, HIKE_B, GPX_HIKE]).types;
|
||||
expect(hike.routedMinutes).toEqual({ value: 300, known: 2, of: 2 });
|
||||
expect(hike.importedMinutes).toEqual({ value: 150, known: 1, of: 1 });
|
||||
// Planned: each tour's own time plus its breaks (HIKE_B's 30 min).
|
||||
expect(hike.plannedMinutes).toEqual({ value: 480, known: 3, of: 3 });
|
||||
});
|
||||
|
||||
it('sums no heights for paddling, not even those a recorded paddle track brought', () => {
|
||||
const [paddle] = tourScopeSummary([PADDLE, { ...GPX_HIKE, place_id: 5, tour_type: 'kayak' }]).types;
|
||||
expect(paddle.ascentM).toEqual({ value: null, known: 0, of: 0 });
|
||||
expect(paddle.descentM).toEqual({ value: null, known: 0, of: 0 });
|
||||
expect(paddle.distanceKm.value).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TourScopeSummary', () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it('shows the counts and each type with its sums, an incomplete one marked as at least', () => {
|
||||
render(<TourScopeSummary tours={[HIKE_A, HIKE_B, PADDLE]} />);
|
||||
const counts = screen.getByTestId('tour-scope-counts');
|
||||
expect(counts).toHaveTextContent('3Tours');
|
||||
expect(counts).toHaveTextContent('2Planned');
|
||||
expect(counts).toHaveTextContent('1Unplanned');
|
||||
const hike = screen.getByTestId('tour-scope-hike');
|
||||
expect(within(hike).getByText('15.5 km')).toBeInTheDocument();
|
||||
expect(within(hike).getByText('≥ 600 m')).toBeInTheDocument();
|
||||
expect(within(hike).getByText('Routed time')).toBeInTheDocument();
|
||||
expect(within(hike).queryByText('Time from imports')).toBeNull();
|
||||
// The paddle tour has no heights: no ascent, rather than 0 m.
|
||||
expect(within(screen.getByTestId('tour-scope-kayak')).queryByText('Ascent')).toBeNull();
|
||||
});
|
||||
|
||||
it('folds to its counts, and remembers that in this browser', () => {
|
||||
const { unmount } = render(<TourScopeSummary tours={[HIKE_A]} />);
|
||||
fireEvent.click(screen.getByTestId('tour-scope-toggle'));
|
||||
expect(screen.queryByTestId('tour-scope-hike')).toBeNull();
|
||||
expect(screen.getByTestId('tour-scope-counts')).toBeInTheDocument();
|
||||
unmount();
|
||||
render(<TourScopeSummary tours={[HIKE_A]} />);
|
||||
expect(screen.queryByTestId('tour-scope-hike')).toBeNull();
|
||||
expect(screen.getByTestId('tour-scope-toggle')).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('shows nothing for a trip without tours', () => {
|
||||
const { container } = render(<TourScopeSummary tours={[]} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { TourListItem, TourTypeKey } from '@trek/shared';
|
||||
import { heightsUnknown } from './tourFacts';
|
||||
import { tourPlannedTimes, tourSource } from './tourPresentation';
|
||||
|
||||
/**
|
||||
* A sum over the tours that have the value, with how many have it of how many
|
||||
* could: `value` is null when none has it, never a zero standing in for
|
||||
* "unknown", and `known < of` says the sum is short of the whole.
|
||||
*/
|
||||
export interface ScopeSum {
|
||||
value: number | null;
|
||||
known: number;
|
||||
of: number;
|
||||
}
|
||||
|
||||
/** One tour type's share of the trip: how many tours, how many on a day, and their measures. */
|
||||
export interface TourTypeScope {
|
||||
type: TourTypeKey;
|
||||
count: number;
|
||||
onDays: number;
|
||||
distanceKm: ScopeSum;
|
||||
ascentM: ScopeSum;
|
||||
descentM: ScopeSum;
|
||||
/** The time the router worked out, for the tours planned in TREK. */
|
||||
routedMinutes: ScopeSum;
|
||||
/** The time an import brought with it (a GPX file, a wanderer trail). */
|
||||
importedMinutes: ScopeSum;
|
||||
/** The time planned for the day, breaks or the planner's own figure included. */
|
||||
plannedMinutes: ScopeSum;
|
||||
}
|
||||
|
||||
export interface TourScope {
|
||||
count: number;
|
||||
onDays: number;
|
||||
/** By type, the most tours first; the time kinds are never added up across types. */
|
||||
types: TourTypeScope[];
|
||||
}
|
||||
|
||||
/** A measure a type has no use for: no value, and nothing it is short of. */
|
||||
const NO_SUM: ScopeSum = { value: null, known: 0, of: 0 };
|
||||
|
||||
function sumOf(values: Array<number | null | undefined>): ScopeSum {
|
||||
const known = values.filter((value): value is number => value != null && Number.isFinite(value));
|
||||
return {
|
||||
value: known.length ? known.reduce((total, value) => total + value, 0) : null,
|
||||
known: known.length,
|
||||
of: values.length,
|
||||
};
|
||||
}
|
||||
|
||||
function scopeOfType(type: TourTypeKey, tours: readonly TourListItem[]): TourTypeScope {
|
||||
const planned = tours.filter((tour) => tourSource(tour) === 'trek');
|
||||
const imported = tours.filter((tour) => tourSource(tour) !== 'trek');
|
||||
// A paddle tour drawn in TREK has no true heights (they would be the banks'), so it does not count as having them.
|
||||
const withHeights = tours.map((tour) => (heightsUnknown(tour) ? null : tour));
|
||||
// On the water a climb says nothing: paddling sums no heights, whatever a recorded track brought.
|
||||
const heights = type !== 'kayak';
|
||||
return {
|
||||
type,
|
||||
count: tours.length,
|
||||
onDays: tours.filter((tour) => tour.planned).length,
|
||||
distanceKm: sumOf(tours.map((tour) => tour.distance)),
|
||||
ascentM: heights ? sumOf(withHeights.map((tour) => tour?.elevation_gain)) : NO_SUM,
|
||||
descentM: heights ? sumOf(withHeights.map((tour) => tour?.elevation_loss)) : NO_SUM,
|
||||
routedMinutes: sumOf(planned.map((tour) => tour.duration)),
|
||||
importedMinutes: sumOf(imported.map((tour) => tour.duration)),
|
||||
plannedMinutes: sumOf(tours.map((tour) => tourPlannedTimes(tour).plannedTotalMinutes)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* STA-01: what the tours of a trip add up to, by type. Each tour counts once,
|
||||
* however many days it is on; the list is the trip's tours, one row each, so
|
||||
* a tour on three days is still one tour and one distance.
|
||||
*/
|
||||
export function tourScopeSummary(tours: readonly TourListItem[]): TourScope {
|
||||
const byType = new Map<TourTypeKey, TourListItem[]>();
|
||||
for (const tour of new Map(tours.map((tour) => [tour.place_id, tour])).values()) {
|
||||
byType.set(tour.tour_type, [...(byType.get(tour.tour_type) ?? []), tour]);
|
||||
}
|
||||
const types = [...byType.entries()]
|
||||
.map(([type, ofType]) => scopeOfType(type, ofType))
|
||||
.sort((a, b) => b.count - a.count || a.type.localeCompare(b.type));
|
||||
return {
|
||||
count: types.reduce((total, scope) => total + scope.count, 0),
|
||||
onDays: types.reduce((total, scope) => total + scope.onDays, 0),
|
||||
types,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user