forked from OurHike/OurHike
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathplannedHike.ts
More file actions
143 lines (130 loc) · 5.62 KB
/
Copy pathplannedHike.ts
File metadata and controls
143 lines (130 loc) · 5.62 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
// The hike a person says they are on: where they started, where they are
// going, and nothing else (#335).
//
// Two numbers. Everything the app needs from them - which way is "ahead",
// where a route ends - falls out of the pair, and every field beyond them is
// features/HIKE_PLANNING.md arguing its way into v1 early. That doc is v2's
// first feature and this is deliberately not a small version of it: there is
// no route builder here, no days, no resupply, no timeline.
//
// WHY THIS IS NOT A PREFERENCE
//
// `UserPreferences` syncs to backend/app/schemas/preferences.py, which is
// `extra="forbid"` - so a key invented on the client becomes a 422 the moment
// somebody signs in. That is #242, already open and already filed once. A hike
// is not a display setting anyway; it gets its own key.
//
// WHY THE DIRECTION IS NOT STORED
//
// backend/app/models/hike.py made this call first and the reasoning carries
// over unchanged: "No `direction` column, deliberately. Whether a hike is NOBO
// or SOBO is fully determined by comparing overall_start_reference to
// overall_end_reference - storing a separate `direction` value would just be a
// second source of truth that could drift from the references it's derived
// from."
//
// WHAT THIS IS NOT SYNCED TO
//
// `POST /hikes` exists and is complete, and nothing here calls it. Pushing a
// hike to the server raises "which device wins" the moment there are two, and
// an offline-first app has already answered that - the phone does - but
// writing that down properly is its own change. #247 is the feature that needs
// the server to know, so #247 is where it belongs.
import { get, set, del } from 'idb-keyval'
import type { HikeDirection } from '../chrome/Header'
export const PLANNED_HIKE_KEY = 'ourhike:hike'
export interface PlannedHike {
/** Miles from the southern terminus, where this hike begins. */
startMile: number
/** Where it ends. Smaller than `startMile` for a southbound hike - that is
* the whole of how direction is known. */
endMile: number
}
/**
* Which way this hike runs.
*
* Total, with no undefined arm, and that is the point of validating on the way
* in: `plannedHike` refuses a pair that cannot answer this, so nothing
* downstream has to carry a third case that only a rejected value could
* produce.
*/
export function plannedDirection(hike: PlannedHike): HikeDirection {
return hike.endMile > hike.startMile ? 'NOBO' : 'SOBO'
}
/**
* A hike, or null if these two numbers cannot describe one.
*
* Refused rather than corrected. A start equal to its end has no direction and
* covers no trail, and a hiker who typed the same number twice meant
* something this cannot guess - silently nudging one end by a tenth of a mile
* would invent a heading and then use it to decide which closures to warn
* about.
*
* `trailMiles` bounds them where it is known. It comes from the centerline
* index rather than a constant (lib/trailPosition.ts's `totalMiles`), so a
* phone that has not finished downloading the trail yet passes undefined and
* gets range checking only against zero - which is the honest amount of
* checking available at that moment.
*/
export function plannedHike(
startMile: number,
endMile: number,
trailMiles?: number,
): PlannedHike | null {
if (!Number.isFinite(startMile) || !Number.isFinite(endMile)) return null
if (startMile === endMile) return null
if (startMile < 0 || endMile < 0) return null
if (trailMiles !== undefined && (startMile > trailMiles || endMile > trailMiles)) {
return null
}
return { startMile, endMile }
}
/** The whole trail, in the given direction. */
export function wholeTrail(direction: HikeDirection, trailMiles: number): PlannedHike {
return direction === 'NOBO'
? { startMile: 0, endMile: trailMiles }
: { startMile: trailMiles, endMile: 0 }
}
/**
* What is stored, or null.
*
* Re-validated on the way out rather than trusted, the same call
* lib/preferences.ts makes about a stored background it no longer recognises:
* this is a value an earlier build wrote, and a pair that cannot describe a
* hike must not reach the code that decides which way "ahead" is. Null is
* already the state every screen handles - it is what a hiker who has not set
* one has.
*/
export async function loadPlannedHike(): Promise<PlannedHike | null> {
const stored = (await get(PLANNED_HIKE_KEY)) as Partial<PlannedHike> | undefined
if (stored === undefined || stored === null) return null
return plannedHike(stored.startMile as number, stored.endMile as number)
}
export async function savePlannedHike(hike: PlannedHike): Promise<void> {
await set(PLANNED_HIKE_KEY, hike)
}
/**
* Forget it.
*
* A first-class action rather than an omission: finishing a hike, or changing
* plans on the trail, must not mean clearing the app's data. Everything in
* this app works without a hike - that is the state a hiker starts in, and it
* has to be one they can get back to.
*/
export async function clearPlannedHike(): Promise<void> {
await del(PLANNED_HIKE_KEY)
}
/**
* The hike in one line, for the row that opens the picker.
*
* Here rather than in the screen because two things will want it - the More
* row today, and whatever surfaces a hike on the map later - and a summary
* written twice is a summary that eventually disagrees with itself.
*/
export function hikeSummary(hike: PlannedHike): string {
const way = plannedDirection(hike) === 'NOBO' ? 'Northbound' : 'Southbound'
const [low, high] = [hike.startMile, hike.endMile].sort((a, b) => a - b)
const miles = (value: number) =>
value.toLocaleString('en-US', { maximumFractionDigits: 1 })
return `${way} · mi ${miles(low)} – ${miles(high)}`
}