forked from OurHike/OurHike
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathApp.tsx
More file actions
2080 lines (1968 loc) · 94.1 KB
/
Copy pathApp.tsx
File metadata and controls
2080 lines (1968 loc) · 94.1 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
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// The app shell: what screen is showing, and where its data comes from.
//
// There is no router. Every screen is reached from the tab bar or from a flow
// that owns its own back-out, so URLs would be a second navigation model to
// keep in sync with the first for no gain a hiker would notice - and the
// service worker precaches one document either way.
//
// Two tabs, Trail and More (chrome/tabs.ts). Downloads was a third until
// 2026-08-05 and is now a window this file opens over whichever of them is
// showing - see screens/DownloadsDialog.tsx for why, and `downloadsWindow`
// below for what goes in it.
//
// Sign-in reaches the reporting flow through stepAfterSaving() (lib/
// contributionFlow.ts), and only ever after the report is already in the
// outbox. That ordering is the promise the flow exists to keep: someone asked
// to authenticate on a ridge with one bar can decline, or simply fail, and
// still have what they wrote.
//
// This used to be deliberately unwired, on the grounds that provider buttons
// which cannot authenticate would break exactly that promise. They can now -
// there is a real Supabase project (features/AUTHENTICATION.md), and Supabase
// Auth is what a hiker signs in to, not this project's own backend.
//
// Queued reports now send too (#231): useOutboxSync below flushes the outbox
// once there is both a connection and an account. Saving is still what the
// flow GUARANTEES, and that ordering has not moved - sending is what happens
// afterwards, if it can, and a build with no VITE_API_BASE_URL simply never
// gets that far.
//
// Which providers appear is a build-time answer (lib/supabase.ts's
// ENABLED_PROVIDERS), because a button whose credentials do not exist yet
// reaches an error page rather than an account.
//
// Identity - trail name and reporter type - is still not collected here.
// stepAfterSaving() reports when it is wanted and there is no screen for it,
// so that step ends the flow the way it already ended, with the report
// queued.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { Map as MapLibreMap } from 'maplibre-gl'
import { MapScreen } from './chrome/MapScreen'
import type { PoiDetail } from './chrome/PoiCard'
import { TabBar } from './chrome/TabBar'
import { ErrorBoundary, ScreenFailed } from './chrome/ErrorBoundary'
import type { TabId } from './chrome/tabs'
import { Downloads } from './screens/Downloads'
import {
hikingDetailOptions,
noDetailOptions,
rasterDetailOptions,
} from './screens/DetailPicker'
import { DownloadsDialog } from './screens/DownloadsDialog'
import { More, type StuckReport } from './screens/More'
import { Moderation } from './screens/Moderation'
import { InstallPrompt } from './screens/InstallPrompt'
import {
ENTRY_CARD_MAX_VIEWPORT_FRACTION,
Onboarding,
type OnboardingResult,
} from './screens/Onboarding'
import { ReportForm, type ReportFormSubmission } from './screens/ReportForm'
import { ReportTypePicker, type ReportTypeId } from './screens/ReportTypePicker'
import { CORRIDOR_ARCHIVE_URL } from './map/protocol'
import { DATA_CONFIGURED } from './lib/config'
import { loadPreferences, savePreferences } from './lib/preferences'
import {
hiddenTypesFrom,
onlyType,
showAllTypes,
toggleType,
} from './lib/waypointVisibility'
import {
DEFAULT_PREFERENCES,
type BackgroundSource,
type HikingDetailLevel,
type ReporterType,
type UserPreferences,
} from './lib/userPreferences'
import {
detailLevelForZoom,
getDownloadDetail,
type DetailLevel,
} from './lib/downloadDetail'
import { useArchiveDownloads } from './lib/useArchiveDownload'
import { useDrawnPoiCounts } from './lib/useDrawnPoiCounts'
import { useAvailableBytes } from './lib/useAvailableBytes'
import { useArchiveZooms } from './lib/useArchiveZooms'
import { archiveCoversZoom } from './lib/archiveCoverage'
import { HEALTHY, type LiveSourceHealth, type SourceReport } from './map/liveSourceHealth'
import {
backgroundProblem,
forgetPackages,
rememberNotDrawing,
sheetNotDrawing,
} from './lib/backgroundHealth'
import {
BASEMAP_PACKAGE,
CORRIDOR_BACKGROUND_PACKAGE,
HIKING_SHEET,
offeredPackages,
offeredSheets,
packageArtifactKey,
packageDownloadUrl,
packageSizeBytes,
sheetSizeBytes,
USGS_SHEET,
type BackgroundSheet,
} from './lib/packages'
import { combineBackgroundStatus } from './lib/backgroundStatus'
import { activeDownload } from './lib/downloadActivity'
import { useClock } from './lib/useClock'
import { useOnline } from './lib/useOnline'
import { useDataSaver } from './lib/useDataSaver'
import { backgroundOverride, effectiveBackground } from './lib/dataSaver'
import { useFinePointer } from './lib/useFinePointer'
import { useTheme } from './lib/useTheme'
import { useDesktop } from './lib/useDesktop'
import { useInstallPrompt } from './lib/useInstallPrompt'
import { useAppUpdate, UPDATE_CHECK_MS } from './lib/useAppUpdate'
import { readCamera, writeCamera } from './lib/cameraMemory'
import { useGeolocation } from './lib/useGeolocation'
import { positionLine } from './lib/positionLine'
import { locateOnTrail, mileOnTrail } from './lib/trailPosition'
import type { StoredPoi } from './lib/trailData'
import { useTrailData } from './lib/useTrailData'
import { ribbonSamples, ribbonWindow } from './lib/elevationProfile'
import { upcomingClimb } from './lib/upcomingClimb'
import { startTracking, trackDirection, type DirectionTracker } from './lib/hikeDirection'
import { beginContribution, stepAfterSaving } from './lib/contributionFlow'
import { useModerator } from './lib/useModerator'
import { hasStatedReporterType, signReportAs } from './lib/reporterIdentity'
import { IdentitySetup } from './screens/IdentitySetup'
import { SignInPrompt, type AuthProvider } from './screens/SignInPrompt'
import { EmailSignIn } from './screens/EmailSignIn'
import { ENABLED_PROVIDERS } from './lib/supabase'
import { TRAILS } from './lib/trails'
import { useAccount } from './lib/useAuth'
import {
sendMagicLink,
signInWithEmail,
signInWithProvider,
signOut,
signUpWithEmail,
} from './lib/auth'
import { listQueued, removeQueued, retryQueued, type FlushResult } from './lib/outbox'
import { useOutboxSync, syncOutbox } from './lib/outboxSync'
import { conditionsAgeLabel, worstOf } from './lib/conditionState'
import { useConditions } from './lib/useConditions'
import { closureBanner, closureLanes, type RankedClosure } from './lib/closureBanner'
import {
atcBandCandidates,
atcPointNotices,
atcUpdateBanner,
atcUpdateForBandId,
atcUpdateLanes,
type RankedAtcUpdate,
} from './lib/atcUpdates'
import { atcUpdatePoints } from './map/atcUpdateLayers'
import {
atcAlertsSince,
readAtcAlertSilence,
writeAtcAlertSilence,
} from './lib/atcAlertsBanner'
import { AtcUpdateSheet } from './chrome/AtcUpdateSheet'
import { AtcNoticeList } from './chrome/AtcNoticeList'
import { HikePicker } from './screens/HikePicker'
import {
clearPlannedHike,
hikeSummary,
loadPlannedHike,
plannedDirection,
savePlannedHike,
type PlannedHike,
} from './lib/plannedHike'
import { closureBands } from './map/closureLayers'
import {
isSeriousWarning,
placeAll,
routeBannerText,
warningsOnRoute,
} from './lib/seriousWarnings'
import type { BoundingBox, MapPoint } from './lib/legendContents'
import type { SearchablePoi } from './lib/searchPoi'
import { siteRoster } from './map/poiSites'
import './App.css'
// Last, and entirely inside media queries - see the file header. Nothing in it
// can match a phone, which is how the WEBSITE.md §8 constraint is kept
// structurally rather than by review.
import './desktop.css'
// OurHike hikes one trail today - see lib/trails.ts for why this is a lookup
// and not just a string.
const TRAIL_NAME = TRAILS.AT.name
const TRAIL_LOGO = TRAILS.AT.logo
// Sync and export are rendered and do nothing: what they need is the backend,
// which is Phase 2 (ROADMAP.md). They share one placeholder rather than
// getting an identical empty arrow each.
//
// Sign in and sign out used to be here too. They are real now - Supabase Auth
// is a separate service from this project's backend, so signing in never
// needed that backend to exist, only a project to sign in to.
const notYet = () => undefined
// The whole trail, Springer to Katahdin, as the opening view. Taken from the
// published topo archive's own header bounds, so it frames exactly the ground
// the map actually covers rather than a hand-typed guess.
//
// Opening on the entire corridor rather than a point on it because before there
// is a GPS fix the app genuinely does not know where the hiker is, and Harpers
// Ferry - the previous default - is a confident-looking answer to that question
// that is wrong for everyone not standing in Harpers Ferry. A view of the whole
// trail says "somewhere on this" honestly.
//
// And it stays that view. The first fix used to zoom the camera to it, which
// takes the map away from anyone reading it - planning a resupply, looking at a
// stretch two states north - for no reason beyond the phone having worked out
// where they are. The camera is the hiker's from the first frame; the locate
// control (map/mapChrome.ts) is how they ask to be taken to themselves, and it
// is a tap away in the thumb zone.
const CORRIDOR_BOUNDS: [[number, number], [number, number]] = [
[-84.73, 34.2],
[-68.3, 46.34],
]
const EMPTY_BBOX: BoundingBox = { west: 0, south: 0, east: 0, north: 0 }
/** Where a search result lands. Only ever zooms IN: someone already at 16
* looking at a spring does not want to be pulled back out to see a shelter. */
const SEARCH_RESULT_ZOOM = 14
interface Camera {
center: [number, number]
zoom: number
}
/**
* One stored POI as the waypoint card takes it.
*
* Built from both arrays on purpose: the POI itself carries the geometry and the
* provenance, and `searchablePois` has already paid for the `locateOnTrail()`
* call that places it on the trail, so the mile in the card is the same number
* search puts on the same POI rather than a second computation that could
* disagree with it.
*
* One function because there are two callers and the tapped waypoint is in both
* of them: it is the card's subject, and it is also the anchor chip of its own
* site's strip (#526). Two spots computing this shape is two spots that can put
* two different miles on one card.
*/
function cardDetail(poi: StoredPoi, searchable: readonly SearchablePoi[]): PoiDetail {
return { ...poi, mile: searchable.find((candidate) => candidate.id === poi.id)?.mile }
}
type ReportingState = null | { step: 'pick' } | { step: 'form'; type: ReportTypeId }
// Sign-in is its own flow rather than another step of the reporting one,
// because it is reachable from two places that want different things back:
// finishing a contribution, and the account row in Settings. Conflating them
// would mean the Settings path inheriting the report flow's copy, which
// promises that a report is already saved - true in one case and not the
// other.
type AuthFlowState = null | { screen: 'choose' | 'email'; afterReport: boolean }
function App() {
// Two pieces of state rather than one nullable, because null only ever meant
// "not read off the phone yet" - and saying that with a boolean keeps the
// preferences themselves always a whole object. That removes an unreachable
// null check from every reader below, including one inside updatePreferences
// that no caller could ever satisfy: nothing renders, and so nothing can
// change a preference, until the load has finished.
//
// Starting from DEFAULT_PREFERENCES rather than a placeholder is not a
// behaviour change: the two values anything read before the load completes
// (location_permission_requested, max_background_zoom) were already falling
// back to exactly these defaults.
const [preferences, setPreferences] = useState<UserPreferences>(DEFAULT_PREFERENCES)
const [preferencesLoaded, setPreferencesLoaded] = useState(false)
const [activeTab, setActiveTab] = useState<TabId>('trail')
// The download window (screens/DownloadsDialog.tsx), which replaced the tab
// it used to be. Held here rather than on either screen because it opens
// over both of them, from the one background picker they share.
const [downloadsOpen, setDownloadsOpen] = useState(false)
/**
* Which background sources are known NOT to be drawing - remembered here
* rather than on the map screen, because the downloads window outlives that
* screen (#334) and is where a hiker acts on it.
*
* Remembered, not mirrored, and lib/backgroundHealth.ts's
* `rememberNotDrawing` owns the whole rule: a source that has drawn clears
* itself, a source that errored without ever drawing sets itself, and a
* source that has done neither leaves this alone. That last clause is what
* carries a real failure across the teardown a trip to the More tab costs,
* and the first is what stops a transient error condemning a good archive
* for the rest of the session - #352, which is the shape this state should
* have had from the start.
*/
const [notDrawing, setNotDrawing] = useState<LiveSourceHealth>(HEALTHY)
const [legendOpen, setLegendOpen] = useState(false)
const [searchOpen, setSearchOpen] = useState(false)
// The tapped pin, held as an id rather than as the POI itself. Everything the
// card shows is derived below, so a POI that changes underneath - a fresh
// download, or the hiker deleting the one they had - is described correctly
// or closes itself, instead of the card going on showing a copy of data the
// app no longer holds.
const [selectedPoiId, setSelectedPoiId] = useState<string | null>(null)
// Derived from the STORED preference rather than held in a `useState` (#530).
// `waypoint_types_shown` had been declared in the preferences model, in the
// backend schema and in IDENTITY_AND_PRIVACY.md's canonical model since long
// before this control, and was read by nothing - so hiding privies lasted
// until the next reload and never reached an account.
const hiddenTypes = useMemo(
() => hiddenTypesFrom(preferences.waypoint_types_shown),
[preferences.waypoint_types_shown],
)
// The legend's "Verified?" filter. Off by default: an unconfirmed spring is
// still the best information anyone has about that spring, and a first run
// that quietly withheld it would be the app deciding for a hiker what they
// are allowed to know about. Ephemeral, exactly like hiddenTypes - both are
// #530's problem, not this one's.
const [verifiedOnly, setVerifiedOnly] = useState(false)
const [bbox, setBbox] = useState<BoundingBox>(EMPTY_BBOX)
const [reporting, setReporting] = useState<ReportingState>(null)
const [authFlow, setAuthFlow] = useState<AuthFlowState>(null)
/**
* Whether the identity screen is showing (#233).
*
* `stepAfterSaving()` has reported this step since the flow was designed and
* there was no screen to show for it, so the branch did nothing and every
* report went out signed `thru`. screens/IdentitySetup.tsx was built and
* tested for exactly this and imported by nothing.
*/
const [collectingIdentity, setCollectingIdentity] = useState(false)
/**
* Whether this session has already asked. Skipping is allowed and does NOT
* write a reporter type - inventing one is the bug being closed - so
* without this a hiker who skips is asked again on their very next report.
* Once per session is the balance: never nagging, and never silently
* deciding they are a day hiker because they closed a screen.
*/
const identityAsked = useRef(false)
// Null until a stored session is read, and null forever if nobody signs in.
// Signed out is the state every screen already works in, so this gates
// nothing.
const account = useAccount()
const [queuedCount, setQueuedCount] = useState(0)
const [stuckReports, setStuckReports] = useState<StuckReport[]>([])
const [selectedAtcBandId, setSelectedAtcBandId] = useState<string | null>(null)
/**
* Whether the full list of ATC notices is open.
*
* Separate from `selectedAtcBandId` rather than a third state of it. The two
* answer different questions - "which one did they tap" and "did they ask to
* read all of them" - and a hiker who opens the list, taps a band behind it
* and closes that sheet should find the list still where they left it.
*/
const [atcNoticesOpen, setAtcNoticesOpen] = useState(false)
/** The newest ATC edit the hiker has already silenced on this phone, or
* null - lib/atcAlertsBanner.ts's watermark, read once at mount and
* written back every time silencing happens. */
const [atcAlertSilence, setAtcAlertSilence] = useState<Date | null>(() =>
readAtcAlertSilence(),
)
// What the hiker SAID they are doing, as against what the GPS works out
// below. Null is the ordinary state rather than an incomplete setup (#335).
const [hike, setHike] = useState<PlannedHike | null>(null)
const [pickingHike, setPickingHike] = useState(false)
// Whether the moderation queue is open, and whether it may be. The role is
// read once per sign-in (lib/useModerator.ts) and decides only whether the
// entry point exists - the backend gates every call regardless (#235).
const [moderating, setModerating] = useState(false)
const isModerator = useModerator(account !== null)
const [direction, setDirection] = useState<DirectionTracker | null>(null)
// The live map is state rather than a ref because effects have to run when
// it appears. It appears more than once: the map screen unmounts whenever
// another tab is showing, so every trip through More builds a new one. The
// download no longer costs one - that is a window over this screen, not a
// tab beside it.
const [map, setMap] = useState<MapLibreMap | null>(null)
// Where the camera was left, so a rebuilt map opens where the hiker left it
// instead of snapping back to the whole corridor.
//
// Seeded from session storage (#311). A service-worker update restarts the
// page, and while that now waits for a moment nobody is watching
// (lib/useAppUpdate.ts), the restart still forgets the view - so a hiker who
// put the phone away reading a junction took it out again looking at the
// whole trail. Null on a fresh tab, which is the corridor, deliberately.
const [camera, setCamera] = useState<Camera | null>(() => readCamera())
const now = useClock()
const online = useOnline()
// What the trail is like right now - closures, reports, the ATC's own
// notices - and when something last reached the server. See
// lib/useConditions.ts for the two tiers behind the first two.
const {
closures,
reports,
closureState,
reportState,
atcUpdates,
atcReviewedAt,
drought,
droughtWeek,
lastSyncedAt,
markSynced,
} = useConditions(online)
// The centerline, the POIs, the elevation profile, and the fetch that puts
// them on the phone - see lib/useTrailData.ts. Everything below reads these;
// nothing else writes them.
const {
trailIndex,
pois,
elevation,
trailsUrl,
haveTrailLines,
error: dataError,
ensure: ensureTrailData,
} = useTrailData(online)
/**
* The map's source observations, folded in; its withdrawals, dropped.
*
* A withdrawal is a map saying it no longer speaks for anything, which is
* not evidence about the archive on the phone - dropping it here is what
* lets the failure survive the walk to the More tab. Everything else is
* `rememberNotDrawing`'s decision. Stable across renders, as MapViewProps
* requires of this handler.
*/
const recordSourceHealth = useCallback((report: SourceReport) => {
if (report.withdrawn) return
setNotDrawing((remembered) => rememberNotDrawing(remembered, report))
}, [])
// Read here rather than inside the map, so the settings screen and the canvas
// are answering from the same value - a row that says "live" over a map
// drawing the archive would be the exact mismatch this feature exists to
// avoid.
const saveData = useDataSaver()
// Decides whether the map gets zoom buttons - see lib/useFinePointer.ts.
// Read here rather than inside MapView so the whole map screen answers from
// one value.
const finePointer = useFinePointer()
// Resolves 'auto' against the OS, writes `data-theme` for the stylesheets,
// and hands back what actually got drawn - which the map needs as a prop,
// because a WebGL canvas cannot read a CSS variable (map/style.ts's
// attachMapAppearance).
//
// Called above the `preferencesLoaded` gate below, like every other hook
// here: it runs on DEFAULT_PREFERENCES for the tick before the phone's own
// answer lands, and that default is 'auto' - the same thing main.tsx already
// stamped on the document before React started.
const resolvedTheme = useTheme(preferences.theme)
// Whether this is the big-screen layout - and, for the download, whether the
// machine is one that goes up a mountain. See handleOnboardingComplete.
const isDesktop = useDesktop()
const install = useInstallPrompt()
// What a reload would destroy right now (#311). Every one of these is React
// state that no storage carries: a report being written, a window or sheet
// the hiker opened, a sign-in half done. The update waits for all of them to
// be put away AND for the page to be hidden - see lib/useAppUpdate.ts.
//
// The camera is deliberately NOT in this list. It is kept across the reload
// instead (lib/cameraMemory.ts), because holding an update for as long as
// someone is looking at a map would hold it for the whole hike.
const updateWouldCost =
reporting !== null ||
authFlow !== null ||
downloadsOpen ||
legendOpen ||
searchOpen ||
selectedPoiId !== null
useAppUpdate(UPDATE_CHECK_MS, { hold: updateWouldCost })
useEffect(() => {
void loadPreferences().then(
(stored) => {
setPreferences(stored)
setPreferencesLoaded(true)
},
// A storage read that rejects - private browsing, an evicted database -
// must not keep the gate below closed: `preferencesLoaded` false renders
// NOTHING, and a rejection here left the app a permanently blank page
// with the map a tick away the whole time. Defaults are the honest
// fallback; the preferences another session stored are unreachable
// either way.
() => setPreferencesLoaded(true),
)
}, [])
// Nothing waits on this. A hike changes what the banners can say and
// nothing about whether the app renders, so unlike preferences it gets no
// `loaded` gate: a hiker who set one two states ago has their banners a tick
// later, and one who never did is already in the state this resolves to.
// A rejected read leaves it null for the same reason - null is what "no
// hike" already means everywhere.
useEffect(() => {
void loadPlannedHike().then(setHike, () => setHike(null))
}, [])
// Two facts, not one number. A report waiting for signal resolves itself;
// a report the server refused never will, and showing them as one count is
// what let a phone with a wrong clock say "waiting to send" forever (#243).
const refreshOutbox = useCallback(async () => {
const queue = await listQueued()
setQueuedCount(queue.filter((item) => item.failure === undefined).length)
setStuckReports(
queue
.filter((item) => item.failure !== undefined)
.map((item) => ({ id: item.id, reason: item.failure!.reason })),
)
}, [])
useEffect(() => {
void refreshOutbox()
}, [reporting, refreshOutbox])
// Sending is the one thing that waits for signal. Everything else a hiker
// does - writing the report, reading the map - already happened offline.
//
// Keyed on having an account as well as a connection because a report can be
// written before signing in: the flow saves first and asks about identity
// afterwards (lib/contributionFlow.ts), so the queue can hold reports that
// no token could have sent yet. Signing in later is a second, equally valid
// moment to try.
const handleSynced = useCallback(
({ sent, stuck }: FlushResult) => {
// Only on a real delivery. Stamping the clock after a flush that sent
// nothing would make "synced just now" mean "we had signal", which is
// the opposite of what the strip is for (lib/syncAge.ts).
if (sent > 0) markSynced()
// Refreshed even when nothing was sent, because a flush that only
// discovered a refusal still changed what the hiker needs to see -
// that is the whole point of the stuck state.
if (sent > 0 || stuck > 0) void refreshOutbox()
},
[refreshOutbox, markSynced],
)
// Written through to the phone before the state moves, so a hiker who sets
// a hike and immediately kills the app has it on the next launch. The same
// order updatePreferences uses, and for the same reason.
const handleSaveHike = useCallback(async (next: PlannedHike) => {
await savePlannedHike(next)
setHike(next)
setPickingHike(false)
}, [])
const handleClearHike = useCallback(async () => {
await clearPlannedHike()
setHike(null)
setPickingHike(false)
}, [])
useOutboxSync(online && account !== null, handleSynced)
/**
* Clears the refusal and sends, now - the escape hatch for a cause the
* hiker has just fixed.
*
* The flush is the part that was missing (#266). Clearing the failure on
* its own only relabels the report: the sole flush trigger is
* useOutboxSync's effect, whose deps are both referentially stable, and
* outboxSync is "deliberately not on a timer" - so on a steady connection
* nothing ran, and the screen swapped "could not be sent" plus its reason
* for "waiting to send" at the exact moment nothing was going to try. That
* is the lie this whole feature exists to remove, told by the button meant
* to fix it.
*
* refreshOutbox runs in `finally` rather than after the flush, because the
* failure has been cleared either way - a retry with no signal has to leave
* the report reading as waiting, not as refused.
*/
const handleRetryReport = useCallback(
(id: string) => {
void retryQueued(id)
.then(() => syncOutbox())
.then((result) => {
if (result !== null && result.sent > 0) markSynced()
})
.finally(() => void refreshOutbox())
},
[refreshOutbox, markSynced],
)
const handleDiscardReport = useCallback(
(id: string) => {
void removeQueued(id).then(refreshOutbox)
},
[refreshOutbox],
)
const locationAllowed = preferences.location_permission_requested
const gps = useGeolocation(locationAllowed)
const detailLevel: DetailLevel = detailLevelForZoom(preferences.max_background_zoom)
// The hiking sheet's own level (#276) - a separate dial from the USGS
// raster's tier above, because the two sheets' choices must never share one.
const hikingLevel = preferences.hiking_detail_level
// Feet or metres, for every screen and the canvas alike (#619, lib/units.ts).
// Read once here and handed down, the same way the resolved theme is: two
// reads of one preference is how a banner in miles ends up over a map in
// kilometres.
const units = preferences.unit_system
// The background sheets a hiker can choose between (#237), and every
// archive behind them (#192). One flat download store underneath - the
// per-sheet grouping is a fact about what a card shows, not about how
// bytes are held.
const backgroundSheets = useMemo(() => offeredSheets(), [])
const downloadRequests = useMemo(
() =>
backgroundSheets
.flatMap((sheet) => offeredPackages(sheet))
.map((pkg) => ({
packageKey: pkg.idbKey,
url: packageDownloadUrl(pkg, detailLevel, hikingLevel),
artifactKey: packageArtifactKey(pkg, detailLevel, hikingLevel),
})),
[backgroundSheets, detailLevel, hikingLevel],
)
const {
statusFor: archiveStatusFor,
errorFor: archiveErrorFor,
statusesKnown: archivesRead,
start: startPackage,
startAll: startPackages,
remove: removePackage,
persistence: archivePersistence,
} = useArchiveDownloads(downloadRequests)
// What the phone can still hold, so a level it cannot is greyed where it is
// chosen rather than refused after the tap (#555). Re-read after a delete
// below: freeing space is the app's own printed remedy, and #554 measured
// that the browser's accounting may never notice on its own.
const { bytes: availableBytes, refresh: refreshAvailableBytes } = useAvailableBytes()
/** One sheet as one state, however many archives are behind it. */
const sheetStatus = useCallback(
(sheet: BackgroundSheet) =>
combineBackgroundStatus(
offeredPackages(sheet).map((pkg) => ({
status: archiveStatusFor(pkg.idbKey),
sizeBytes: packageSizeBytes(pkg, detailLevel, hikingLevel),
})),
),
[archiveStatusFor, detailLevel, hikingLevel],
)
/** The first of this sheet's archives with something to report. One card
* per sheet, so one message - and the archives are not something a hiker
* was told about, so naming which of them failed would explain nothing.
* Per sheet rather than global, so one sheet's failure can never render
* on the other's card. */
const sheetError = useCallback(
(sheet: BackgroundSheet) =>
offeredPackages(sheet)
.map((pkg) => archiveErrorFor(pkg.idbKey))
.find(Boolean) ?? null,
[archiveErrorFor],
)
// Whether the corridor raster specifically is on the phone. Asked about
// that archive rather than about the background as a whole, because it is
// the archive the offline background is DRAWN from - archiveZooms reads its
// header, and effectiveBackground decides against its presence.
const archiveStatus = archiveStatusFor(CORRIDOR_BACKGROUND_PACKAGE.idbKey)
// Whether there is a corridor on this phone at all. Only a FINISHED archive
// counts: a partial one is bytes in IndexedDB that the PMTiles source cannot
// read, so treating "downloading" or "failed" as downloaded would honour an
// offline background against an archive that draws nothing - the exact state
// effectiveBackground exists to keep a hiker out of.
const archiveDownloaded = archiveStatus.state === 'downloaded'
// Whether ANY sheet's archive is here - what words the DownloadsLink
// ("choose" vs "change"). Distinct from archiveDownloaded since #237: the
// hiking sheet downloading without the USGS raster is now a normal phone.
const anySheetDownloaded = backgroundSheets.some((sheet) =>
offeredPackages(sheet).some(
(pkg) => archiveStatusFor(pkg.idbKey).state === 'downloaded',
),
)
/**
* Which sheets are in the step BEFORE their transfer - fetching the trail
* data that has to land first (`ensureTrailData`).
*
* State, and rendered, because it used to be neither. The canary is 12.3 MB
* of trails.geojson, and until it finished nothing on the card changed at
* all: the button a hiker had just pressed sat there unchanged, no status,
* no figure, for as long as that took on their connection. The download had
* genuinely started; the app simply had no way to say so, which is the same
* complaint the footer bar exists to answer, one step earlier in the flow.
*
* Per sheet rather than one flag because the card that reports it is per
* sheet - and the trail data being shared is exactly why two sheets tapped
* together can both be in this step off one fetch.
*/
const [preparingSheets, setPreparingSheets] = useState<readonly string[]>([])
// What is arriving right now, across every sheet, for the link that says so
// (lib/downloadActivity.ts). Decided here rather than on either screen for
// the reason the transfer itself lives here: the download outlives the
// window it was started from and has to be reportable from the map and from
// Settings alike, which are never both mounted. Off the SHEET statuses the
// cards already render, so the footer's figure and the card's cannot
// disagree about the same download.
const downloadActivity = activeDownload(
backgroundSheets.map(sheetStatus),
preparingSheets.length > 0,
)
// Whether the hiking sheet's TILES are on the phone - the basemap package
// alone, not the sheet as a whole. The DEM beside it is the same sheet's
// terrain, and a missing hillshade is not what makes a background fail to
// draw. Read by the status strip to tell "your download is not drawing"
// from "you have no download" (lib/backgroundHealth.ts, #314).
const hikingSheetDownloaded =
archiveStatusFor(BASEMAP_PACKAGE.idbKey).state === 'downloaded'
// What the archive on this phone actually covers, read from its own header
// rather than assumed from the pipeline's constants (#216). Null until a
// finished archive exists to ask, and null again if it is deleted.
const archiveZooms = useArchiveZooms(
CORRIDOR_BACKGROUND_PACKAGE.idbKey,
archiveDownloaded,
)
// A fix moves no camera - see CORRIDOR_BOUNDS. It is read for everything
// else: the mile below, the direction of travel, the elevation ribbon.
const fix = useMemo(() => {
if (trailIndex === null || gps.status !== 'located') return null
return locateOnTrail(trailIndex, gps.at)
}, [trailIndex, gps])
useEffect(() => {
if (fix === null) return
setDirection((previous) =>
previous === null ? startTracking(fix.mile) : trackDirection(previous, fix.mile),
)
}, [fix])
/**
* Which way this hiker is walking, from whichever source knows.
*
* OBSERVATION WINS, and the plan fills the gap it leaves. `hikeDirection.ts`
* waits for a quarter mile of movement before it will commit - deliberately,
* so a GPS wandering under tree cover at a lunch stop does not flip the
* header - and until then a declared hike is the only thing that can answer
* the question at all. That quarter mile is exactly the stretch a hiker
* leaving a trailhead walks with no banner (#335).
*
* The ordering is worth being deliberate about, because the two can
* disagree: somebody who said NOBO and is measurably walking south is
* either turned around or has changed their mind, and this app cannot tell
* which. Trusting the plan over the observation would redefine "ahead" as
* the way they are NOT going and warn about closures behind them, which is
* the worse of the two failures. Telling them they are walking the wrong
* way is the wrong-way alert's job (#93, #247), not this line's.
*/
const heading =
direction?.direction ?? (hike === null ? undefined : plannedDirection(hike))
/**
* The closure a hiker is about to walk into, in one line, or null.
*
* Needs a mile and a closure list; the direction goes down as whatever is
* known, undefined included. "Ahead" is meaningless before the app knows
* which way someone is walking - a guess would put a NOBO hiker's closure
* behind them - but standing INSIDE a closure needs no direction at all,
* and direction takes a quarter mile of walking to establish
* (lib/hikeDirection.ts). Gating the whole banner on it kept the app
* silent for exactly the first quarter mile of a closed section, which is
* where the warning matters most. closureBanner.ts owns that split.
*/
const { closureAhead, advisoryAhead } = useMemo(() => {
if (fix === null) return { closureAhead: null, advisoryAhead: null }
// Two sources compete for each line: OurHike's verified closures and the
// ATC's own notices (#461). Nearest wins, which is the rule the two lane
// functions already apply WITHIN their own list - "the closure two hundred
// miles north is not the one that changes what they do next" - extended
// across both rather than replaced by a precedence between the sources.
// Neither deserves one: an ATC notice is authoritative about the trail they
// maintain, and a verified closure was checked by a moderator, so ranking
// them would be inventing a claim about which organisation is more right.
// Which one is in front of the hiker is a fact, and it is the fact that
// decides what they do next.
//
// The winner is then written in its OWN voice - "Trail closed 2.1 mi
// ahead" for ours, "ATC · Closure 2.1 mi ahead · <their headline>" for
// theirs. That is the whole of #461's requirement in the one place a
// hiker reads without tapping anything.
//
// TWO LINES, NOT ONE (#485). A closure that is a stretch of trail and an
// advisory that is a region answer different questions - "what do I do next"
// against "what country am I in" - so they do not compete. Ranked together,
// standing inside a 398-mile advisory scored 0 and buried the nine-mile
// closure three miles ahead for 398 miles of walking. The rule is written
// once per source (`closureLanes`, `atcUpdateLanes`) and the source tie is
// broken here, the same way, for each lane.
const closureLane = closureLanes(closures ?? [], fix.mile, heading)
const atcLane = atcUpdateLanes(atcUpdates, fix.mile, heading)
// Whichever source the hiker reaches first, in that source's own voice.
// `<=` keeps ours first on an exact tie, which is arbitrary and has to be
// something; it matters only when both name the same mile.
const pick = (
closure: RankedClosure | null,
atc: RankedAtcUpdate | null,
): string | null => {
if (closure !== null && (atc === null || closure.distance <= atc.distance)) {
return closureBanner(closure.closure, fix.mile, heading, units)
}
if (atc !== null) return atcUpdateBanner(atc.update, fix.mile, heading, units)
return null
}
return {
closureAhead: pick(closureLane.specific, atcLane.specific),
advisoryAhead: pick(closureLane.broad, atcLane.broad),
}
}, [closures, atcUpdates, fix, heading, units])
/**
* Serious warnings between here and the end of the trail, counted.
*
* Placing them is `placeAll`'s job (#244), which snaps lat/lon against this
* same trail index where it can and falls back to the mile the reporting
* phone recorded where it cannot - the case that used to be uncountable, a
* report filed against a POI with no coordinates.
*
* `severity` filtering is `warningsOnRoute`'s job, so a report that a
* moderator has not escalated cannot reach this line.
*/
const warningsAhead = useMemo(() => {
if (
reports === null ||
trailIndex === null ||
fix === null ||
heading === undefined
) {
return null
}
const placed = placeAll(reports, trailIndex)
// Where the ROUTE ends, which is the phrase the banner uses. A declared
// hike answers it exactly; without one the terminus is as far as "ahead"
// can honestly go, and "on your route" quietly means the two thousand
// miles between here and Katahdin (#335).
//
// Clamped to the direction actually being walked. A hiker heading north
// who declared a southbound hike would otherwise get a range running
// backwards past them, and `warningsOnRoute` normalises it into a count of
// everything BEHIND them - a banner about warnings they have already
// passed. Falling back to the terminus in that case says less and says it
// truthfully.
const declaredEnd = hike === null ? null : hike.endMile
const terminus = heading === 'NOBO' ? trailIndex.totalMiles : 0
const routeEnd =
declaredEnd !== null &&
(heading === 'NOBO' ? declaredEnd >= fix.mile : declaredEnd <= fix.mile)
? declaredEnd
: terminus
return routeBannerText(
warningsOnRoute(placed, { fromMile: fix.mile, toMile: routeEnd }).length,
)
}, [reports, trailIndex, fix, heading, hike])
/**
* The same closures on the canvas: a barred red band along each closed
* stretch (lib/closureStyle.ts).
*
* Needs the centerline index and nothing else - no GPS fix, no direction.
* That is the difference from `closureAhead` above and the reason they are
* two memos rather than one: "ahead" is a claim about a hiker, and drawing
* a closed stretch of trail is a claim about the trail. A closure should be
* on the map from the moment it is known, including for someone who has not
* started walking and has no direction yet.
*/
const closureBandsOnMap = useMemo(() => {
if (closures === null || trailIndex === null) return []
return closureBands(closures, trailIndex)
}, [closures, trailIndex])
/**
* The ATC's notices as bands, through exactly the same geometry.
*
* `atcBandCandidates` adapts each update into the shared `Closure` shape and
* `closureBands` does the rest, so an ATC update inherits `trailSlice`'s
* centerline placement and `isBroadAdvisory`'s length ceiling without either
* being reimplemented - which is what #461 means by the geometry path
* needing no new code, and what keeps ATC's 398-mile Helene advisory from
* painting a fifth of the trail (#462).
*
* The candidate filter is where the two differ: only ATC categories that
* mean the trail itself is obstructed become a band, because a barred band
* says "go around" and a notice about a closed car park does not. The rest
* keep the banner, exactly as an over-long advisory does.
*/
const atcBandsOnMap = useMemo(() => {
if (trailIndex === null) return []
return closureBands(atcBandCandidates(atcUpdates), trailIndex)
}, [atcUpdates, trailIndex])
/**
* The same notices that name one mile rather than a stretch, as dots.
*
* Not filtered by `obstructsTheTrail`, unlike the bands. A dot makes no
* claim about passability - it says the ATC has posted something here - so a
* bear warning and a closed shelter both belong on the map, and neither is
* the barrier a band would have made them.
*/
const atcPointsOnMap = useMemo(() => {
if (trailIndex === null) return []
return atcUpdatePoints(atcPointNotices(atcUpdates), trailIndex)
}, [atcUpdates, trailIndex])
/** The tapped update, resolved from the band id the map reported. */
const selectedAtcUpdate = useMemo(() => {
if (selectedAtcBandId === null) return null
return atcUpdateForBandId(atcUpdates, selectedAtcBandId)
}, [atcUpdates, selectedAtcBandId])
/**
* Which notices the canvas is ACTUALLY drawing, by band id.
*
* Read off the two collections above rather than re-derived from the
* updates, and that is the whole point of computing it here. The filters
* (`atcBandCandidates`, `atcPointNotices`) say what this build INTENDS to
* draw; `closureBands` and `atcUpdatePoints` then drop anything whose mile
* falls outside this build's centerline, which no predicate over an
* `AtcUpdate` can know. AtcNoticeList tells a hiker which notices have no
* mark to look for, and it can only be honest about that from the truth.
*/
const atcDrawnIds = useMemo(
() =>
new Set<string>([
...atcBandsOnMap.map((band) => band.id),
...atcPointsOnMap.map((point) => point.id),
]),
[atcBandsOnMap, atcPointsOnMap],
)
/**
* What the bottom "new alerts" banner has to say, or null (#687).
*
* Independent of every filter above - `atcBandCandidates`, `atcPointNotices`
* and the lane functions all decide what belongs on the MAP or in the
* header's one line, and none of that is "has ATC posted something
* recently". An update the map cannot place and the header will never
* mention (behind the hiker, over the band ceiling) is still new, and
* still worth this banner - `chrome/AtcNoticeList.tsx` is the same
* argument for the full list.
*/
const newAtcAlerts = useMemo(
() => atcAlertsSince(atcUpdates, now, atcAlertSilence),
[atcUpdates, now, atcAlertSilence],
)
/**
* Marks every currently-new edit as seen. Wired to both the bottom
* banner's own dismiss and to opening the full list (onOpenAtcNotices
* below) - whichever way a hiker actually looked, the banner has done its
* job and should not return until ATC posts something after this mark.
*/
const silenceAtcAlerts = useCallback(() => {
if (newAtcAlerts === null) return
writeAtcAlertSilence(newAtcAlerts.newestAt)
setAtcAlertSilence(newAtcAlerts.newestAt)
}, [newAtcAlerts])
/**
* Serious warnings as points, straight from the report's own lat/lon.
*
* No `locateOnTrail` here, unlike `warningsAhead`, which needs a mile to
* decide what is on the route. A pin goes where the report was written -
* including the ones a few hundred feet off trail, which `locateOnTrail`
* still places and which a hiker is better off seeing at their real
* position than snapped onto the centerline.
*/
const warningPins = useMemo(() => {
if (reports === null) return []
return reports.flatMap((report) => {
if (!isSeriousWarning(report) || report.lat === null || report.lon === null) {
return []
}
return [{ id: report.id, lon: report.lon, lat: report.lat }]
})
}, [reports])
// Built from the POIs alone. The mile is added where the centerline index
// exists and simply omitted where it does not - searching for a shelter by
// name needs no geometry, and gating the whole list on the index meant a