Record that downloads fill the Cache and ship the built app.
A failed open can repair the shell, and the installed PWA matches Ziele, Einstellungen, and the view download. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+2
-1
@@ -4,4 +4,5 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
dev-dist/
|
dev-dist/
|
||||||
*.local
|
*.local
|
||||||
public/
|
public/kacheln/
|
||||||
|
__pycache__/
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 01: Mehrere Ziele behalten
|
||||||
|
|
||||||
|
**What to build:** The person types Koordinaten as Gitter, Rechtswert, and Hochwert. Each valid Koordinate adds a Ziel instead of replacing the previous one. The Ziele are red points, listed newest first, and each row shows that Koordinate. When a Standort is known, the Entfernung to that Ziel follows the Koordinate, in metres below 1000 m and in kilometres from 1000 m, and it updates as the Standort moves. Adding a Ziel, or tapping its row, centers it at 1:25,000. Auf Ziel is gone. Auf Standort stays. Löschen on a row removes that Ziel and does not move the view. The list remains on the device until each Ziel is deleted. Deleting the whole Cache leaves the Ziele in place.
|
||||||
|
|
||||||
|
**Blocked by:** None (can start immediately).
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
The session contract in the spec is the seam. This ticket owns `enterKoordinate`, `deleteZiel`, `centerOnZiel`, `ziele` on the snapshot, Entfernung per Ziel, and remembered `ziele`. `deleteCache` already exists and must leave those Ziele in place. A repeated Koordinate uses the notice `Diese Koordinate ist schon ein Ziel.`
|
||||||
|
|
||||||
|
- [x] The Ziele section offers Gitter, Rechtswert, and Hochwert, and Gitter starts as `32U MA`.
|
||||||
|
- [x] A valid Koordinate adds a red Ziel. A second Koordinate adds another. The newest stands at the top of the list.
|
||||||
|
- [x] Each row shows that Ziel's Koordinate. When a Standort is known, the Entfernung follows it, in metres below 1000 m and in kilometres from 1000 m, and it updates as the Standort moves. With no Standort, the row shows only the Koordinate.
|
||||||
|
- [x] Adding a Ziel centers the view on it at 1:25,000, even while its Kacheln are blank. Tapping the row centers that Ziel the same way.
|
||||||
|
- [x] Auf Standort remains. There is no Auf Ziel control.
|
||||||
|
- [x] Löschen on a row removes that Ziel without asking and does not move the view.
|
||||||
|
- [x] The same Koordinate cannot be added twice. A repeat is explained in German and leaves the list and the view unchanged.
|
||||||
|
- [x] An invalid Koordinate, or one that no Blatt covers, is explained in German and leaves the list and the view unchanged.
|
||||||
|
- [x] The typed Gitter, Rechtswert, and Hochwert stay visible after a successful entry.
|
||||||
|
- [x] Leaving or reloading the page keeps the Ziele. The Standort is not stored.
|
||||||
|
- [x] A late first Standort, after a pan or an added Ziel, moves only the blue mark.
|
||||||
|
- [x] Deleting the whole Cache leaves the Ziele in place.
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
The screen offers the Koordinate fields in Ziele, lists every Ziel newest first, and centers the one just added or the row that was tapped. A rejected Koordinate changes nothing. A new session keeps the Ziele and drops the Standort.
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
# 02: Ein Ziel auf der Karte setzen oder löschen
|
||||||
|
|
||||||
|
**What to build:** A tap on the Karte deletes the nearest Ziel when one lies within 44 px, and otherwise adds a Ziel at that point. The new Ziel is written as a Koordinate to the metre, appears at the top of the list, and the view stays where it is. A tap on the Standort adds a Ziel there when no Ziel is within 44 px. A tap that no Blatt covers changes nothing. Dragging still pans the Karte. This works without a separate mode.
|
||||||
|
|
||||||
|
**Blocked by:** 01: Mehrere Ziele behalten
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
The session contract in the spec is the seam. This ticket owns `tap(point)`. Near means within 44 screen pixels at the current scale, inclusive. A repeated Koordinate uses the same notice as a typed repeat. A point no Blatt covers does not set a notice.
|
||||||
|
|
||||||
|
- [x] A tap within 44 px of a Ziel deletes the nearest one, at every scale. When two Ziele are equally near, the newer one is deleted.
|
||||||
|
- [x] A tap farther than 44 px from every Ziel adds a Ziel at that point, as a Koordinate to the metre, at the top of the list, and does not move the view.
|
||||||
|
- [x] A tap on the Standort adds a Ziel there when no Ziel is within 44 px.
|
||||||
|
- [x] A tap on a point that no Blatt covers leaves the list and the view unchanged.
|
||||||
|
- [x] A repeated Koordinate from a tap is treated like a repeated typed Koordinate: the list and the view stay unchanged.
|
||||||
|
- [x] Dragging pans the Karte. There is no separate mode for setting or deleting a Ziel.
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
The person taps the Karte to add a red Ziel or to remove the one under the finger. The list from 01 gains or loses that row, and the view stays put.
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
# 03: Downloads füllen den Cache
|
||||||
|
|
||||||
|
**What to build:** Einstellungen replace the Cache section and start collapsed. Inside, Hessen herunterladen stays those words. It is red while any Kachel of Hessen is missing from the Cache, and disabled once every one of them is stored. Starting it fetches only the missing Kacheln, at every prepared scale, the current scale first. A second press while that fill is running adds nothing. Closing the app stops the fill. Opening the app does not continue it. Pressing again fetches what is still missing. While the app stays open, losing the network pauses the fill and regaining it continues it. Looking around still fetches only the current view at the current scale. Ganzen Cache löschen asks first, then removes every Kachel and does not start a fetch. There is no Hessen entfernen.
|
||||||
|
|
||||||
|
**Blocked by:** None (can start immediately).
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
The session contract in the spec is the seam. This ticket owns `startHessen`, `hessen` and `visit` on the snapshot, the shared queue, pause on offline, and stop on close. `deleteCache` drops Kacheln and the visit fills, does not itself start a fetch, and leaves Ziele and the checkbox. There is no `removeGebiet` and no heruntergeladen mark. The screen copy for a running fill is `Kacheln: ${arrived} von ${total}`.
|
||||||
|
|
||||||
|
- [x] Einstellungen replace the Cache section and start collapsed. Hessen herunterladen and Ganzen Cache löschen are inside it.
|
||||||
|
- [x] Hessen herunterladen keeps that label. It is red while any Kachel of Hessen is missing, and disabled, and shown as disabled, once every Kachel of Hessen is in the Cache.
|
||||||
|
- [x] Starting Hessen fetches only missing Kacheln of Hessen, at every prepared scale from 1.25 m per pixel up to the 36 km span. Kacheln of the current scale arrive first.
|
||||||
|
- [x] A second press of Hessen herunterladen, while that fill is running, adds nothing further.
|
||||||
|
- [x] While the fill runs, Einstellungen show how many of the Kacheln requested in this visit have arrived. Kacheln already in the Cache stay usable.
|
||||||
|
- [x] Losing the network while the app stays open pauses the fill. Regaining the network continues it. No fetch runs while the device is offline.
|
||||||
|
- [x] Leaving the app stops the fill. Opening the app does not continue it, and shows no leftover progress. Pressing Hessen herunterladen again fetches only the Kacheln still missing.
|
||||||
|
- [x] Looking around, while online and the app is open, fetches only the missing Kacheln of the current view at the current scale.
|
||||||
|
- [x] The app does not mark a Gebiet heruntergeladen, and it offers no Hessen entfernen.
|
||||||
|
- [x] Ganzen Cache löschen asks "Den ganzen Cache löschen?" first. Declining leaves the Cache unchanged. Accepting removes every Kachel, does not start a fetch, and makes Hessen herunterladen red and active again.
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
The person starts Hessen from Einstellungen. Finished Kacheln stay in the Cache. The fill does not resume on its own, and the only way to drop Kacheln is to delete the whole Cache after confirming.
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
# 04: Die aktuelle Ansicht herunterladen
|
||||||
|
|
||||||
|
**What to build:** The first control in Einstellungen is a checkbox labelled Download-Knopf. It starts off, and the device remembers it. While it is on, a download control sits at the bottom right of the Karte, including when Einstellungen are collapsed. Pressing it fetches every missing Kachel that intersects the view at that moment, at every prepared scale. The set stays fixed if the person then pans. Hessen and this fill share one queue, and Kacheln of the current scale go first. A second press of the same control, while that fill is running, adds nothing. Deleting the whole Cache leaves the checkbox as it was.
|
||||||
|
|
||||||
|
**Blocked by:** 03: Downloads füllen den Cache
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
The session contract in the spec is the seam. This ticket owns `setDownloadKnopf`, `downloadView`, and remembered `downloadKnopf`. The control on the Karte is labelled Diese Ansicht herunterladen. It shares the queue and the `visit` counts with Hessen.
|
||||||
|
|
||||||
|
- [x] The first control in Einstellungen is a checkbox labelled Download-Knopf. The first time, it is off. After that, the device remembers it across leaving and reopening.
|
||||||
|
- [x] While the checkbox is on, a download control sits at the bottom right of the Karte. It stays there when Einstellungen are collapsed, and it is hidden when the checkbox is off.
|
||||||
|
- [x] Pressing it fetches every missing Kachel that intersects the view at the moment of the press, at every prepared scale. A view zoomed out to the 36 km span includes the finer Kacheln inside it.
|
||||||
|
- [x] Panning or zooming after the press does not add Kacheln to that request. Kacheln already in the Cache are skipped. A press that finds nothing missing fetches nothing.
|
||||||
|
- [x] A second press of that same control, while its fill is running, adds nothing further. Pressing Hessen herunterladen while the view fill runs adds Hessen's missing Kacheln on the same queue.
|
||||||
|
- [x] The two fills share one queue. Kacheln of the current scale of the current view are fetched before the rest. Einstellungen show how many of the Kacheln requested in this visit have arrived.
|
||||||
|
- [x] Closing the app stops the view fill. Opening the app does not continue it. Pressing the control again fetches what is still missing.
|
||||||
|
- [x] Deleting the whole Cache leaves the Download-Knopf checkbox as it was.
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
The person turns on Download-Knopf, presses the control on the Karte, and the visible view is fetched at every prepared scale. The fill follows the same pause, stop, and Cache rules as Hessen.
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# Einstellungen und Ziele
|
||||||
|
|
||||||
|
Status: ready-for-agent
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
The Karte keeps one Ziel, drops it when the page is left, and treats a download as a Gebiet marked heruntergeladen. The person cannot keep several places, return to one of them, or save just the view they are looking at. Removing Hessen is a separate action from clearing the Cache, and an unfinished Hessen download continues by itself the next time the app opens.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Einstellungen hold a Download-Knopf switch, the red Hessen download, and deletion of the whole Cache. When the switch is on, a download control on the Karte fetches the Kacheln of the current view at every prepared scale into the Cache. Downloads do not mark a Gebiet. Ziele are a list of red points the person adds, revisits, and deletes, and they stay on the device until the person deletes them.
|
||||||
|
|
||||||
|
## User Stories
|
||||||
|
|
||||||
|
1. As a person using the Karte, I want an Einstellungen control where Cache used to be, so that download and Cache actions sit together.
|
||||||
|
2. As a person using the Karte, I want Einstellungen to start collapsed, so that the Karte is the first thing I see.
|
||||||
|
3. As a person using the Karte, I want the first control inside Einstellungen to be a checkbox labelled Download-Knopf, so that I can choose whether a download control sits on the Karte.
|
||||||
|
4. As a person using the Karte, I want that checkbox to start off the first time, so that the Karte opens without an extra control.
|
||||||
|
5. As a person using the Karte, I want the device to remember the checkbox, so that I do not set it again after reopening.
|
||||||
|
6. As a person using the Karte, I want a download control at the bottom right of the Karte while the checkbox is on, so that I can fetch the view I am looking at.
|
||||||
|
7. As a person using the Karte, I want that control to stay while Einstellungen are collapsed, so that I do not have to keep the panel open.
|
||||||
|
8. As a person using the Karte, I want the control hidden while the checkbox is off, so that the Karte stays clear.
|
||||||
|
9. As a person using the Karte, I want Hessen herunterladen inside Einstellungen, so that the full download lives with the other Cache actions.
|
||||||
|
10. As a person using the Karte, I want Hessen herunterladen to stay those words, so that the control does not turn into a delete.
|
||||||
|
11. As a person using the Karte, I want Hessen herunterladen drawn red while any Kachel of Hessen is missing from the Cache, so that the unfinished download is obvious.
|
||||||
|
12. As a person using the Karte, I want Hessen herunterladen disabled once every Kachel of Hessen is in the Cache, so that I cannot start a finished download.
|
||||||
|
13. As a person using the Karte, I want the disabled Hessen control shown as disabled, so that it is distinct from the red active control.
|
||||||
|
14. As a person using the Karte, I want Ganzen Cache löschen inside Einstellungen, so that wiping Kacheln lives with the downloads.
|
||||||
|
15. As a person using the Karte, I want to be asked "Den ganzen Cache löschen?" before the Cache is wiped, so that I do not clear it by mistake.
|
||||||
|
16. As a person using the Karte, I want declining that question to leave the Cache as it is, so that cancel is safe.
|
||||||
|
17. As a person using the Karte, I want a wiped Cache to make Hessen herunterladen red and active again, so that I can fetch Hessen once more.
|
||||||
|
18. As a person using the Karte, I want a wiped Cache to leave my Ziele and the Download-Knopf checkbox as they are, so that clearing pixels does not clear places I named.
|
||||||
|
19. As a person using the Karte, I want Hessen herunterladen to fetch only Kacheln of Hessen that are not yet in the Cache, so that a second run finishes the remainder.
|
||||||
|
20. As a person using the Karte, I want those Kacheln to cover every prepared scale, from 1.25 m per pixel up to the 36 km span, so that offline zoom has the pixels it needs.
|
||||||
|
21. As a person using the Karte, I want the Kacheln of the scale I am looking at to arrive before the other Hessen Kacheln, so that the place on screen fills first.
|
||||||
|
22. As a person using the Karte, I want a second press of Hessen herunterladen, while that fill is already running, to add nothing further, so that the button cannot restart the same work.
|
||||||
|
23. As a person using the Karte, I want the download control on the Karte to fetch every Kachel that intersects the view at the moment I press it, at every prepared scale, so that I can zoom that place offline.
|
||||||
|
24. As a person using the Karte, I want that set of Kacheln fixed at the press, so that panning afterwards does not enlarge the request.
|
||||||
|
25. As a person using the Karte, I want a wide view to fetch the fine Kacheln inside it as well, so that "every scale" includes a view zoomed out to the 36 km span.
|
||||||
|
26. As a person using the Karte, I want Kacheln already in the Cache to be skipped, so that the press only fetches what is missing.
|
||||||
|
27. As a person using the Karte, I want a press that finds nothing missing to fetch nothing, so that a finished view stays quiet.
|
||||||
|
28. As a person using the Karte, I want a second press of the same download control, while that view fill is already running, to add nothing further, so that the control cannot restart the same work.
|
||||||
|
29. As a person using the Karte, I want Hessen and the view fill to share one queue, so that the device fetches one Kachel at a time.
|
||||||
|
30. As a person using the Karte, I want Kacheln of the current view's scale to go first when both fills are active, so that the place on screen still fills before the rest.
|
||||||
|
31. As a person using the Karte, I want Einstellungen to show how many of the Kacheln requested in this visit have arrived, while a fill is running, so that a long download is not silent.
|
||||||
|
32. As a person using the Karte, I want a network drop while the app stays open to pause the fill, and the return of the network to continue it, so that a short gap does not cancel the visit's request.
|
||||||
|
33. As a person using the Karte, I want leaving the app to stop the fill, so that the phone does not keep fetching in the background.
|
||||||
|
34. As a person using the Karte, I want opening the app to leave that fill stopped, so that a download continues only when I press again.
|
||||||
|
35. As a person using the Karte, I want a later press to fetch the Kacheln that are still missing, so that finished Kacheln are kept.
|
||||||
|
36. As a person using the Karte, I want looking around, while online and the app is open, to fetch only the Kacheln of the current view at the current scale, so that panning does not pull every scale.
|
||||||
|
37. As a person using the Karte, I want a Kachel already in the Cache to be drawn while a fill continues, so that the finished part of the Karte is usable.
|
||||||
|
38. As a person using the Karte, I want no fetch while the device is offline, so that a blank Kachel stays blank until a connection exists.
|
||||||
|
39. As a person using the Karte, I want a Ziele section beside Einstellungen, so that naming places stays separate from downloads.
|
||||||
|
40. As a person using the Karte, I want Ziele to start open, so that the Koordinate fields are where they were.
|
||||||
|
41. As a person using the Karte, I want to type a Koordinate as Gitter, Rechtswert, and Hochwert, with Gitter starting as `32U MA`, so that the usual square is ready.
|
||||||
|
42. As a person using the Karte, I want a valid Koordinate to add a Ziel, so that a second place does not erase the first.
|
||||||
|
43. As a person using the Karte, I want each Ziel drawn in red, so that every named place is visible.
|
||||||
|
44. As a person using the Karte, I want the new Ziel at the top of the list, so that the place I just named is the first row.
|
||||||
|
45. As a person using the Karte, I want each row to show that Ziel's Koordinate, so that I can read the place without guessing from the dot.
|
||||||
|
46. As a person using the Karte, I want the Entfernung after the Koordinate when a Standort is known, so that I can compare how far each Ziel is.
|
||||||
|
47. As a person using the Karte, I want that Entfernung in metres below 1000 m and in kilometres from 1000 m, so that the number stays readable.
|
||||||
|
48. As a person using the Karte, I want each Entfernung to update as the Standort moves, so that the list stays current while I walk.
|
||||||
|
49. As a person using the Karte, I want rows to show only the Koordinate when no Standort is known, so that I am not shown a distance to nowhere.
|
||||||
|
50. As a person using the Karte, I want a new Ziel to center the view on it at 1:25,000, even while its Kacheln are blank, so that I see the place I named.
|
||||||
|
51. As a person using the Karte, I want tapping a row to center that Ziel at 1:25,000, so that I can return to it after panning.
|
||||||
|
52. As a person using the Karte, I want Auf Standort to remain, so that I can still return to myself at 1:25,000.
|
||||||
|
53. As a person using the Karte, I want no Auf Ziel control, so that no single Ziel is treated as the special one.
|
||||||
|
54. As a person using the Karte, I want a Löschen control on each row, so that I can remove that Ziel without moving the view.
|
||||||
|
55. As a person using the Karte, I want Löschen to remove that Ziel without a question, so that deleting one place is the action itself.
|
||||||
|
56. As a person using the Karte, I want the same Koordinate only once, so that two dots cannot occupy one cell.
|
||||||
|
57. As a person using the Karte, I want a repeated Koordinate explained in German, leaving the list and the view unchanged, so that a duplicate is not a second Ziel.
|
||||||
|
58. As a person using the Karte, I want an invalid Koordinate, or one that no Blatt covers, explained in German, leaving the list and the view unchanged, so that a mistake does not drop places I already have.
|
||||||
|
59. As a person using the Karte, I want the typed fields to stay after a successful entry, so that I can check them against the new row.
|
||||||
|
60. As a person using the Karte, I want Ziele to remain on the device until I delete them, so that leaving or reloading the page keeps the list.
|
||||||
|
61. As a person using the Karte, I want a tap on the Karte within 44 px of a Ziel to delete the nearest one, so that I can remove a dot where it sits.
|
||||||
|
62. As a person using the Karte, I want that tap to do this at every scale, so that "near" means near on the screen.
|
||||||
|
63. As a person using the Karte, I want a tap farther than 44 px from every Ziel to add a Ziel at that point, written as a Koordinate to the metre, so that I can name a place without typing.
|
||||||
|
64. As a person using the Karte, I want that added Ziel at the top of the list, without moving the view, so that the Karte stays where I tapped.
|
||||||
|
65. As a person using the Karte, I want a tap on the Standort to add a Ziel there when no Ziel is within 44 px, so that my own position can become a named place.
|
||||||
|
66. As a person using the Karte, I want a tap on a point no Blatt covers to leave the list and the view unchanged, so that a hole between Blätter cannot become a Ziel.
|
||||||
|
67. As a person using the Karte, I want a drag to pan the Karte, so that moving the view is not naming a Ziel.
|
||||||
|
68. As a person using the Karte, I want this tap behavior without a separate mode, so that the Karte always accepts it.
|
||||||
|
69. As a person using the Karte, I want the Standort to stay blue and to stay unstored, so that my own position is still the device's, only while the app is open.
|
||||||
|
70. As a person using the Karte, I want a late first Standort, after I have panned or added a Ziel, to move only the blue mark, so that my view is not yanked away.
|
||||||
|
|
||||||
|
## Implementation Decisions
|
||||||
|
|
||||||
|
- Follow ADR 0001 and ADR 0003. A download writes missing Kacheln into the Cache and does not mark a Gebiet. ADR 0002 is superseded and is not implemented.
|
||||||
|
- The screen talks only to a Karte session. That session is the one test seam. Map drawing, the browser position API, and device storage sit behind it.
|
||||||
|
|
||||||
|
### Session contract
|
||||||
|
|
||||||
|
The screen and the tests share these names. A Ziel on the snapshot is `{ koordinate, point, entfernung }`. `koordinate` is the canonical writing: Gitter uppercased with one space (`32U MA`), plus the five-digit Rechtswert and Hochwert. `entfernung` is `{ metres, text }` in the existing German form, or `null` when no Standort is known. `ziele` is newest first.
|
||||||
|
|
||||||
|
`snapshot()` returns `center`, `resolution`, `northUp`, `ziele`, `standort`, `notice`, `koordinate` (the typed fields, unchanged by success or failure), `downloadKnopf`, `hessen: { arrived, total }`, `visit: { arrived, total } | null`, and `kacheln`. There is no single Ziel, no single Entfernung, and no heruntergeladen flag. `hessen.arrived` counts Hessen Kacheln in the Cache. `visit` is set only while a Hessen or view fill from this visit is unfinished; after it finishes, and after the app is closed, `visit` is `null`.
|
||||||
|
|
||||||
|
`enterKoordinate` adds when the text is valid, a Blatt covers the point, and that canonical Koordinate is not already a Ziel. A repeat sets `notice` to `Diese Koordinate ist schon ein Ziel.` and changes nothing else. The existing German notices stay for an invalid Koordinate and for a point no Blatt covers. Success clears `notice`, appends the Ziel at the front, and centers at 1:25,000.
|
||||||
|
|
||||||
|
`deleteZiel(koordinate)` and `centerOnZiel(koordinate)` address one Ziel by that canonical Koordinate. Delete does not move the view and does not ask. `centerOnZiel` and `centerOnStandort` both use 1:25,000.
|
||||||
|
|
||||||
|
`tap(point)` uses the session's current scale. The radius is 44 screen pixels times metres per pixel, inclusive. The nearest Ziel inside it is deleted; a tie deletes the newer Ziel. Otherwise the point is added as a Koordinate to the metre, under the same Blatt and uniqueness rules, and the view stays. A point no Blatt covers changes nothing, including `notice`. The Standort is not a Ziel.
|
||||||
|
|
||||||
|
`setDownloadKnopf` stores the checkbox. `startHessen` and `downloadView` are the two fill kinds. A second call of the same kind, while that kind is unfinished, adds no Kacheln. The other kind still adds its missing Kacheln. Both share one queue with on-demand fetches: one Kachel at a time, and Kacheln of the current view at the current scale go first. `downloadView` fixes its set at the call. On-demand, from looking around, queues only missing Kacheln of the current view at the current scale. Closing drops both fills and in-flight fetches, and opening does not restore them. Going offline aborts the in-flight Kachel and keeps the unfinished fills; coming back online continues them. No fetch runs while offline or while the app is closed.
|
||||||
|
|
||||||
|
`deleteCache` removes every Kachel, aborts in-flight fetches, drops the visit fills, and does not itself start a fetch. It leaves `ziele` and `downloadKnopf`.
|
||||||
|
|
||||||
|
Remembered state is `{ ziele, downloadKnopf }`. A previous value that only holds heruntergeladen names is read as no Ziele and the checkbox off. The Standort is not stored.
|
||||||
|
- The session exposes the Ziele newest first. Each Ziel carries its Koordinate and, when a Standort is known, its Entfernung in the existing German form (`500 m`, `1,5 km`). There is no single Ziel and no single Entfernung. There is no center-on-Ziel for "the" Ziel. Centering a particular Ziel and centering the Standort are separate requests. Both use 1:25,000.
|
||||||
|
- Entering a Koordinate adds a Ziel when the text is valid, a Blatt covers the point, and that Koordinate is not already a Ziel. The same rules as before decide what is valid: Gitter case and spacing, exactly five digits, a real MGRS pair, and Blatt coverage. A rejection explains itself in German and changes nothing else. A success centers the view even when the Kacheln are blank, and the typed fields stay as entered.
|
||||||
|
- Two Koordinaten are the same Ziel when the normalized Gitter, Rechtswert, and Hochwert match. The list stores that canonical Koordinate.
|
||||||
|
- A tap is a ground point plus the current scale. Near means within 44 screen pixels, converted with the current metres-per-pixel. The nearest Ziel inside that radius is deleted. A tie deletes the newer Ziel. A tap with no Ziel inside the radius adds a Ziel at the metre of that point, using the same Blatt check and the same uniqueness rule, and does not move the view. The Standort is not a Ziel, so a tap on it adds one when the radius is empty.
|
||||||
|
- Deleting a Ziel from the list does not move the view and does not ask. The screen's row control is labelled Löschen.
|
||||||
|
- Ziele and the Download-Knopf checkbox are stored on the device with the other remembered session state. The heruntergeladen names are not stored. A stored list from a previous version that only holds those names is read as no Ziele and the checkbox off. Leaving the session and opening another one with the same storage brings the Ziele and the checkbox back. The Standort is not stored.
|
||||||
|
- The first Standort after opening still centers the view once, and only when the person has not panned and has not added a Ziel since opening. Later Standorte update marks and Entfernungen only.
|
||||||
|
- Hessen herunterladen asks the session to fetch every missing Kachel of Hessen. The view control asks it to fetch every missing Kachel that intersects the view at that moment, at every prepared scale. Both share one queue. Kacheln of the current scale of the current view are fetched before the rest. A second request of the same kind, while that request is still unfinished, does not add Kacheln. Pressing the other control does add its missing Kacheln, with the current scale still first.
|
||||||
|
- The session reports the Hessen Kacheln already in the Cache and how many Hessen Kacheln exist, and the arrived and total counts of the Kacheln requested during this visit. The screen paints Hessen herunterladen red while any Hessen Kachel is missing, and disabled when none are. The red is the same red as a Ziel. While a visit request is unfinished, Einstellungen shows those arrived and total counts. After the app is closed, that visit request is gone, so opening shows no leftover progress.
|
||||||
|
- Fetches run only while the session is told that the app is open and the device is online. Closing aborts the visit request and does not restore it on open. Going offline aborts the in-flight Kachel and keeps the visit request. Coming back online, still in that visit, continues it. Looking around still queues only missing Kacheln of the current view at the current scale.
|
||||||
|
- Deleting the Cache removes every Kachel and does not start a fetch. It leaves Ziele and the checkbox. The screen still asks "Den ganzen Cache löschen?" before it tells the session to delete. There is no remove-Hessen request. Tests drive the session after that confirmation.
|
||||||
|
- Einstellungen is the collapsed section that used to be Cache. Its header control is labelled Einstellungen. Ziele is the open section that used to be the Koordinate fields; its header control reads Ziele when collapsed and Einklappen when open. It keeps Auf Standort. The download control on the Karte is labelled Diese Ansicht herunterladen, sits at the bottom right, and is shown only when the checkbox is on. While `visit` is set, Einstellungen shows `Kacheln: ${arrived} von ${total}`. Hessen herunterladen uses the Ziel red while `hessen.arrived` is below `hessen.total`, and is disabled when they are equal.
|
||||||
|
|
||||||
|
## Testing Decisions
|
||||||
|
|
||||||
|
- A good test drives the Karte session the way the screen will, and asserts what the screen would show. It does not assert how a Kachel is stored, how a library is called, or how a map is drawn.
|
||||||
|
- The only module under test is the Karte session, the same seam as the first version. Tests supply a fake Übersicht, a fake Cache, a fake store for remembered state, and Standort, online, and app-open reports. They do not open a browser.
|
||||||
|
- Cover several Ziele, order, uniqueness, both refusals, persistence across two sessions, Entfernung per Ziel and its absence, centering from an added Koordinate and from a chosen Ziel, tap-to-delete and tap-to-add including the 44 px radius and a point no Blatt covers, Hessen completeness without a stored mark, view-fill extent and scale, view-first queue order, a second press of the same request, pause on offline and stop on close without resume, on-demand limited to the current scale, and Cache deletion leaving Ziele and the checkbox.
|
||||||
|
- The German confirmation for the Cache, the red and disabled painting, and the placement of the download control are screen copy and layout. They are not a second test seam.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Removing Hessen, or any other Gebiet, as its own action.
|
||||||
|
- Remembering that a Gebiet was chosen, and continuing a fill merely because the app opened.
|
||||||
|
- A stored name for the view that was fetched. The view is the set of Kacheln chosen at the press.
|
||||||
|
- Deleting every Ziel in one action.
|
||||||
|
- Changing how a Koordinate is parsed, how the Standort is obtained, the blue Standort, north-up, the 1:25,000 center, or the 36 km and 1.25 m zoom limits.
|
||||||
|
- Routing, a typed Standort, and a Standort kept while the app is closed.
|
||||||
|
|
||||||
|
## Further Notes
|
||||||
|
|
||||||
|
- Where the first-version spec still says there is one Ziel, that a Ziel is dropped on leave, that Hessen is marked heruntergeladen, that opening the app continues a fill, or that Hessen can be removed on its own, this spec replaces it.
|
||||||
|
- The sample Koordinate `32U MA 67030 78109` is still a covered point. A second Ziel needs a different Koordinate the Übersicht also covers.
|
||||||
+7
-7
@@ -1,6 +1,6 @@
|
|||||||
# KartenApp
|
# KartenApp
|
||||||
|
|
||||||
A topographic map of these DTK25 sheets. The user names one Ziel on it and, when the device can provide it, sees their own Standort.
|
A topographic map of these DTK25 sheets. The user names Ziele on it and, when the device can provide it, sees their own Standort.
|
||||||
|
|
||||||
## Language
|
## Language
|
||||||
|
|
||||||
@@ -28,16 +28,12 @@ _Avoid_: Verlauf, Speicher
|
|||||||
A named set of Kacheln. Gebiete may overlap. Hessen is the Gebiet that contains every Kachel currently in the Übersicht.
|
A named set of Kacheln. Gebiete may overlap. Hessen is the Gebiet that contains every Kachel currently in the Übersicht.
|
||||||
_Avoid_: Area, Region, Ausschnitt
|
_Avoid_: Area, Region, Ausschnitt
|
||||||
|
|
||||||
**Heruntergeladen**:
|
|
||||||
A Gebiet the user has chosen to keep. The mark belongs to the Gebiet, not to a single Kachel.
|
|
||||||
_Avoid_: Gecacht, Im Cache
|
|
||||||
|
|
||||||
**Ziel**:
|
**Ziel**:
|
||||||
The one position the user names on the Karte.
|
One position the user names on the Karte. The Karte holds any number of them.
|
||||||
_Avoid_: Standort, Wegpunkt
|
_Avoid_: Standort, Wegpunkt
|
||||||
|
|
||||||
**Koordinate**:
|
**Koordinate**:
|
||||||
The MGRS writing of the Ziel: Gitter, Rechtswert, and Hochwert. It is not a bare UTM easting and northing.
|
The MGRS writing of a Ziel: Gitter, Rechtswert, and Hochwert. It is not a bare UTM easting and northing.
|
||||||
_Avoid_: Standort, UTM-Zahlen, GPS, Punkt
|
_Avoid_: Standort, UTM-Zahlen, GPS, Punkt
|
||||||
|
|
||||||
**Gitter**:
|
**Gitter**:
|
||||||
@@ -55,3 +51,7 @@ _Avoid_: Northing, Y, part 3
|
|||||||
**Standort**:
|
**Standort**:
|
||||||
The user's own position, when the device can provide it.
|
The user's own position, when the device can provide it.
|
||||||
_Avoid_: Koordinate, GPS-Punkt, location
|
_Avoid_: Koordinate, GPS-Punkt, location
|
||||||
|
|
||||||
|
**Entfernung**:
|
||||||
|
The straight-line distance between the Standort and one Ziel. It exists only while a Standort is known.
|
||||||
|
_Avoid_: Route, Weg, Distanz
|
||||||
|
|||||||
@@ -32,3 +32,11 @@ Jede Nutzung der Geobasisdaten, der zugehörigen Metadaten und der Daten der Imm
|
|||||||
Wird bei der Nutzung der Geobasisdaten oder Metadaten ein Quellenvermerk beigegeben, ist in diesem auf Veränderungen, Bearbeitungen, neue Gestaltungen oder sonstige Abwandlungen der Geobasisdaten oder Metadaten hinzuweisen.
|
Wird bei der Nutzung der Geobasisdaten oder Metadaten ein Quellenvermerk beigegeben, ist in diesem auf Veränderungen, Bearbeitungen, neue Gestaltungen oder sonstige Abwandlungen der Geobasisdaten oder Metadaten hinzuweisen.
|
||||||
Eine Ausnahme besteht für personenbezogene Daten. Diese dürfen nur für den Zweck genutzt werden, der das berechtigte Interesse am Zugang zu diesen Daten begründet und zu dessen Erfüllung die betreffenden Daten übermittelt wurden. Eine Weiterverwendung für andere Zwecke ist unzulässig.
|
Eine Ausnahme besteht für personenbezogene Daten. Diese dürfen nur für den Zweck genutzt werden, der das berechtigte Interesse am Zugang zu diesen Daten begründet und zu dessen Erfüllung die betreffenden Daten übermittelt wurden. Eine Weiterverwendung für andere Zwecke ist unzulässig.
|
||||||
|
|
||||||
|
## (Neu-)Erstellung der Karten
|
||||||
|
|
||||||
|
Die DTK25 Karten herunterladen und in ./Karten bereitstellen (Datein mit Namen DTK25_560000_56000000_col.tif bzw. .tfw)
|
||||||
|
|
||||||
|
Dann im Projektverzeichnis aufrufen:
|
||||||
|
```bash
|
||||||
|
python3 prepare/prepare_kacheln.py --karten Karten --uebersicht public/uebersicht.json --tiles public --all
|
||||||
|
```
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ status: accepted
|
|||||||
|
|
||||||
# Offline PWA with prepared raster tiles
|
# Offline PWA with prepared raster tiles
|
||||||
|
|
||||||
The Karte is an installable app for phone and tablet, with no app store and no live map server. The Mac turns the DTK25 Blätter into Kacheln once. A Kachel reaches the device because the user downloads a Gebiet, or because the open Karte needs it and the device is online. The open Karte reads only the Kacheln inside the current view. The furthest zoom-out is a ground span shipped in the app configuration, 36 km by 36 km unless that file is changed. The prepared Kacheln cover every scale from that span down to the native sheet pixel. Downloading a Gebiet fetches all of those scales, the current view first. Downloads run only while that app is open and the device is online; leaving the app pauses them, and opening it again continues them. Which of those Kacheln a delete keeps is [ADR 0002](0002-cached-tiles-follow-downloaded-gebiete.md).
|
The Karte is an installable app for phone and tablet, with no app store and no live map server. The Mac turns the DTK25 Blätter into Kacheln once. A Kachel reaches the device because the user fetches it into the Cache, or because the open Karte needs it and the device is online. The open Karte reads only the Kacheln inside the current view. The furthest zoom-out is a ground span shipped in the app configuration, 36 km by 36 km unless that file is changed. The prepared Kacheln cover every scale from that span down to the native sheet pixel. Fetching Hessen, or fetching the Kacheln that intersect the current view, covers every one of those scales, the Kacheln of the current scale first. Fetches run only while that app is open and the device is online. Leaving the app stops them, and opening it again does not continue them. While the app stays open, losing the network pauses a fetch and regaining it continues that fetch. Looking around fetches only the current view at the current scale. Kacheln leave the device only when the whole Cache is deleted. Why a download does not mark a Gebiet is [ADR 0003](0003-downloads-fill-the-cache.md).
|
||||||
|
|
||||||
A macOS desktop app was rejected. Serving the original GeoTIFFs from the Mac while the phone is on the same network was rejected, because the Karte has to work after the download, with that computer left behind.
|
A macOS desktop app was rejected. Serving the original GeoTIFFs from the Mac while the phone is on the same network was rejected, because the Karte has to work after the download, with that computer left behind.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
status: accepted
|
status: superseded by ADR-0003
|
||||||
---
|
---
|
||||||
|
|
||||||
# Cached Kacheln follow the Gebiete still marked downloaded
|
# Cached Kacheln follow the Gebiete still marked downloaded
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
status: accepted
|
||||||
|
---
|
||||||
|
|
||||||
|
# Downloads fill the Cache
|
||||||
|
|
||||||
|
A download writes missing Kacheln into the Cache and does not mark a Gebiet. Hessen means every Kachel of that Gebiet. The current view means every Kachel that intersects the view at the moment of the request, at every prepared scale. Closing the app stops that fetch, and opening the app does not continue it; the same request fetches what is still missing. While the app stays open, losing the network pauses the fetch and regaining it continues it. Hessen is complete when every one of its Kacheln is in the Cache. Kacheln leave the device only when the whole Cache is deleted.
|
||||||
|
|
||||||
|
Remembering which Gebiet a download had claimed was rejected. That mark existed so removing one Gebiet could spare a Kachel still claimed by another, and so a download could resume when the app opened. Selective removal and resume-on-open are both dropped.
|
||||||
|
|
||||||
|
Supersedes [ADR 0002](0002-cached-tiles-follow-downloaded-gebiete.md) and the resume-on-open and delete-retention sentences of [ADR 0001](0001-offline-pwa-prepared-tiles.md).
|
||||||
@@ -4,15 +4,15 @@ Issues and specs for this repo live as markdown files in `.scratch/`.
|
|||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- One feature per directory: `.scratch/<feature-slug>/`
|
- One feature per directory, named with the local date and time when it is created: `.scratch/<YYYY-MM-DD>_<HH-mm>-<feature-slug>/`. Example: `.scratch/2026-10-09_07-31-einstellungen-und-ziele/`. The clock is 24-hour local time. Every new directory directly under `.scratch/` starts with that prefix, including wayfinding efforts.
|
||||||
- The spec is `.scratch/<feature-slug>/spec.md`
|
- The spec is `.scratch/<YYYY-MM-DD>_<HH-mm>-<feature-slug>/spec.md`
|
||||||
- Implementation issues are one file per ticket at `.scratch/<feature-slug>/issues/<NN>-<slug>.md`, numbered from `01`, never a single combined tickets file
|
- Implementation issues are one file per ticket at `.scratch/<YYYY-MM-DD>_<HH-mm>-<feature-slug>/issues/<NN>-<slug>.md`, numbered from `01`, never a single combined tickets file
|
||||||
- Triage state is recorded as a `Status:` line near the top of each issue file (see `triage-labels.md` for the role strings)
|
- Triage state is recorded as a `Status:` line near the top of each issue file (see `triage-labels.md` for the role strings)
|
||||||
- Comments and conversation history append to the bottom of the file under a `## Comments` heading
|
- Comments and conversation history append to the bottom of the file under a `## Comments` heading
|
||||||
|
|
||||||
## When a skill says "publish to the issue tracker"
|
## When a skill says "publish to the issue tracker"
|
||||||
|
|
||||||
Create a new file under `.scratch/<feature-slug>/` (creating the directory if needed).
|
Create a new file under `.scratch/<YYYY-MM-DD>_<HH-mm>-<feature-slug>/` (creating the directory if needed). Take the date and time at the moment the directory is created. Do not invent a second prefix for files added later inside that directory.
|
||||||
|
|
||||||
## When a skill says "fetch the relevant ticket"
|
## When a skill says "fetch the relevant ticket"
|
||||||
|
|
||||||
@@ -22,9 +22,9 @@ Read the file at the referenced path. The user will normally pass the path or th
|
|||||||
|
|
||||||
Used by `/wayfinder`. The **map** is a file with one **child** file per ticket.
|
Used by `/wayfinder`. The **map** is a file with one **child** file per ticket.
|
||||||
|
|
||||||
- **Map**: `.scratch/<effort>/map.md` (the Notes / Decisions-so-far / Fog body).
|
- **Map**: `.scratch/<YYYY-MM-DD>_<HH-mm>-<effort>/map.md` (the Notes / Decisions-so-far / Fog body).
|
||||||
- **Child ticket**: `.scratch/<effort>/issues/NN-<slug>.md`, numbered from `01`, with the question in the body. A `Type:` line records the ticket type (`research`/`prototype`/`grilling`/`task`); a `Status:` line records `claimed`/`resolved`.
|
- **Child ticket**: `.scratch/<YYYY-MM-DD>_<HH-mm>-<effort>/issues/NN-<slug>.md`, numbered from `01`, with the question in the body. A `Type:` line records the ticket type (`research`/`prototype`/`grilling`/`task`); a `Status:` line records `claimed`/`resolved`.
|
||||||
- **Blocking**: a `Blocked by: NN, NN` line near the top. A ticket is unblocked when every file it lists is `resolved`.
|
- **Blocking**: a `Blocked by: NN, NN` line near the top. A ticket is unblocked when every file it lists is `resolved`.
|
||||||
- **Frontier**: scan `.scratch/<effort>/issues/` for files that are open, unblocked, and unclaimed; first by number wins.
|
- **Frontier**: scan `.scratch/<YYYY-MM-DD>_<HH-mm>-<effort>/issues/` for files that are open, unblocked, and unclaimed; first by number wins.
|
||||||
- **Claim**: set `Status: claimed` and save before any work.
|
- **Claim**: set `Status: claimed` and save before any work.
|
||||||
- **Resolve**: append the answer under an `## Answer` heading, set `Status: resolved`, then append a context pointer (gist + link) to the map's Decisions-so-far in `map.md`.
|
- **Resolve**: append the answer under an `## Answer` heading, set `Status: resolved`, then append a context pointer (gist + link) to the map's Decisions-so-far in `map.md`.
|
||||||
|
|||||||
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
|||||||
|
try{self[`workbox:window:7.4.0`]&&_()}catch{}function e(e,t){return new Promise((function(n){var r=new MessageChannel;r.port1.onmessage=function(e){n(e.data)},e.postMessage(t,[r.port2])}))}function t(e,t){(t==null||t>e.length)&&(t=e.length);for(var n=0,r=Array(t);n<t;n++)r[n]=e[n];return r}function n(e,t,n){return t&&function(e,t){for(var n=0;n<t.length;n++){var r=t[n];r.enumerable=r.enumerable||!1,r.configurable=!0,`value`in r&&(r.writable=!0),Object.defineProperty(e,a(r.key),r)}}(e.prototype,t),Object.defineProperty(e,"prototype",{writable:!1}),e}function r(e,n){var r=typeof Symbol<`u`&&e[Symbol.iterator]||e[`@@iterator`];if(r)return(r=r.call(e)).next.bind(r);if(Array.isArray(e)||(r=function(e,n){if(e){if(typeof e==`string`)return t(e,n);var r={}.toString.call(e).slice(8,-1);return r===`Object`&&e.constructor&&(r=e.constructor.name),r===`Map`||r===`Set`?Array.from(e):r===`Arguments`||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)?t(e,n):void 0}}(e))||n){r&&(e=r);var i=0;return function(){return i>=e.length?{done:!0}:{done:!1,value:e[i++]}}}throw TypeError(`Invalid attempt to iterate non-iterable instance.
|
||||||
|
In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}function i(e,t){return i=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(e,t){return e.__proto__=t,e},i(e,t)}function a(e){var t=function(e,t){if(typeof e!=`object`||!e)return e;var n=e[Symbol.toPrimitive];if(n!==void 0){var r=n.call(e,t);if(typeof r!=`object`)return r;throw TypeError(`@@toPrimitive must return a primitive value.`)}return String(e)}(e,`string`);return typeof t==`symbol`?t:t+``}try{self[`workbox:core:7.4.0`]&&_()}catch{}var o=function(){var e=this;this.promise=new Promise((function(t,n){e.resolve=t,e.reject=n}))};function s(e,t){var n=location.href;return new URL(e,n).href===new URL(t,n).href}var c=function(e,t){this.type=e,Object.assign(this,t)};function l(e,t,n){return e&&e.then||(e=Promise.resolve(e)),t?e.then(t):e}function u(){}var d={type:`SKIP_WAITING`};function f(e,t){return e&&e.then?e.then(u):Promise.resolve()}var p=function(t){function r(e,n){var r,i;return n===void 0&&(n={}),(r=t.call(this)||this).nn={},r.tn=0,r.rn=new o,r.en=new o,r.on=new o,r.un=0,r.an=new Set,r.cn=function(){var e=r.fn,t=e.installing;r.tn>0||!s(t.scriptURL,r.sn.toString())||performance.now()>r.un+6e4?(r.vn=t,e.removeEventListener(`updatefound`,r.cn)):(r.hn=t,r.an.add(t),r.rn.resolve(t)),++r.tn,t.addEventListener(`statechange`,r.ln)},r.ln=function(e){var t=r.fn,n=e.target,i=n.state,a=n===r.vn,o={sw:n,isExternal:a,originalEvent:e};!a&&r.mn&&(o.isUpdate=!0),r.dispatchEvent(new c(i,o)),i===`installed`?r.wn=self.setTimeout((function(){i===`installed`&&t.waiting===n&&r.dispatchEvent(new c(`waiting`,o))}),200):i===`activating`&&(clearTimeout(r.wn),a||r.en.resolve(n))},r.yn=function(e){var t=r.hn,n=t!==navigator.serviceWorker.controller;r.dispatchEvent(new c(`controlling`,{isExternal:n,originalEvent:e,sw:t,isUpdate:r.mn})),n||r.on.resolve(t)},r.gn=(i=function(e){var t=e.data,n=e.ports,i=e.source;return l(r.getSW(),(function(){r.an.has(i)&&r.dispatchEvent(new c(`message`,{data:t,originalEvent:e,ports:n,sw:i}))}))},function(){var e=[...arguments];try{return Promise.resolve(i.apply(this,e))}catch(e){return Promise.reject(e)}}),r.sn=e,r.nn=n,navigator.serviceWorker.addEventListener(`message`,r.gn),r}var a,u=t;(a=r).prototype=Object.create(u.prototype),a.prototype.constructor=a,i(a,u);var p=r.prototype;return p.register=function(e){var t=(e===void 0?{}:e).immediate,n=t!==void 0&&t;try{var r=this;return l(function(e,t){var n=e();return n&&n.then?n.then(t):t(n)}((function(){if(!n&&document.readyState!==`complete`)return f(new Promise((function(e){return window.addEventListener(`load`,e)})))}),(function(){return r.mn=!!navigator.serviceWorker.controller,r.dn=r.pn(),l(r.bn(),(function(e){r.fn=e,r.dn&&(r.hn=r.dn,r.en.resolve(r.dn),r.on.resolve(r.dn),r.dn.addEventListener(`statechange`,r.ln,{once:!0}));var t=r.fn.waiting;return t&&s(t.scriptURL,r.sn.toString())&&(r.hn=t,Promise.resolve().then((function(){r.dispatchEvent(new c(`waiting`,{sw:t,wasWaitingBeforeRegister:!0}))})).then((function(){}))),r.hn&&(r.rn.resolve(r.hn),r.an.add(r.hn)),r.fn.addEventListener(`updatefound`,r.cn),navigator.serviceWorker.addEventListener(`controllerchange`,r.yn),r.fn}))})))}catch(e){return Promise.reject(e)}},p.update=function(){try{return this.fn?l(f(this.fn.update())):l()}catch(e){return Promise.reject(e)}},p.getSW=function(){return this.hn===void 0?this.rn.promise:Promise.resolve(this.hn)},p.messageSW=function(t){try{return l(this.getSW(),(function(n){return e(n,t)}))}catch(e){return Promise.reject(e)}},p.messageSkipWaiting=function(){this.fn&&this.fn.waiting&&e(this.fn.waiting,d)},p.pn=function(){var e=navigator.serviceWorker.controller;return e&&s(e.scriptURL,this.sn.toString())?e:void 0},p.bn=function(){try{var e=this;return l(function(e,t){try{var n=e()}catch(e){return t(e)}return n&&n.then?n.then(void 0,t):n}((function(){return l(navigator.serviceWorker.register(e.sn,e.nn),(function(t){return e.un=performance.now(),t}))}),(function(e){throw e})))}catch(e){return Promise.reject(e)}},n(r,[{key:`active`,get:function(){return this.en.promise}},{key:`controlling`,get:function(){return this.on.promise}}])}(function(){function e(){this.Pn=new Map}var t=e.prototype;return t.addEventListener=function(e,t){this.jn(e).add(t)},t.removeEventListener=function(e,t){this.jn(e).delete(t)},t.dispatchEvent=function(e){e.target=this;for(var t,n=r(this.jn(e.type));!(t=n()).done;)(0,t.value)(e)},t.jn=function(e){return this.Pn.has(e)||this.Pn.set(e,new Set),this.Pn.get(e)},e}());export{p as Workbox};
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 782 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#1e3328" />
|
||||||
|
<title>Karte</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-COdGoMDk.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-Bq5oV54d.css">
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest"></head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"name":"Karte","short_name":"Karte","description":"DTK25-Karte für das Gelände","start_url":"/","display":"standalone","background_color":"#e7e1d4","theme_color":"#1e3328","lang":"de","scope":"/","icons":[{"src":"icon-192.png","sizes":"192x192","type":"image/png"},{"src":"icon-512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"}]}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
if(!self.define){let e,i={};const s=(s,n)=>(s=new URL(s+".js",n).href,i[s]||new Promise(i=>{if("document"in self){const e=document.createElement("script");e.src=s,e.onload=i,document.head.appendChild(e)}else e=s,importScripts(s),i()}).then(()=>{let e=i[s];if(!e)throw new Error(`Module ${s} didn’t register its module`);return e}));self.define=(n,o)=>{const r=e||("document"in self?document.currentScript.src:"")||location.href;if(i[r])return;let t={};const c=e=>s(e,r),l={module:{uri:r},exports:t,require:c};i[r]=Promise.all(n.map(e=>l[e]||c(e))).then(e=>(o(...e),t))}}define(["./workbox-9c191d2f"],function(e){"use strict";self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"index.html",revision:"4b7e956a9ca85f71f7f6363bdde1c8a5"},{url:"assets/workbox-window.prod.es5-Bd17z0YL.js",revision:null},{url:"assets/index-COdGoMDk.js",revision:null},{url:"assets/index-Bq5oV54d.css",revision:null},{url:"icon-192.png",revision:"b9806dfbc0396e0ce5c1934273075639"},{url:"icon-512.png",revision:"847487db4af2d4a5e51e9b3e1fdcb649"},{url:"uebersicht.json",revision:"04c71c9ace32f1fbd7eaf16a9b5c8512"},{url:"manifest.webmanifest",revision:"2a757cea886716141758fc7b936d45c2"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("index.html")))});
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,80 @@
|
|||||||
|
import {
|
||||||
|
bootFailureText,
|
||||||
|
clearShellRepairTried,
|
||||||
|
deleteKachelCache,
|
||||||
|
markShellRepairTried,
|
||||||
|
repairAppShell,
|
||||||
|
shellRepairWasTried,
|
||||||
|
} from "./boot-repair";
|
||||||
|
|
||||||
|
function memoryFlags() {
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
return {
|
||||||
|
getItem: (key: string) => values.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => {
|
||||||
|
values.set(key, value);
|
||||||
|
},
|
||||||
|
removeItem: (key: string) => {
|
||||||
|
values.delete(key);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("repairing a failed open", () => {
|
||||||
|
it("drops the service worker and every cache except the Kacheln", async () => {
|
||||||
|
const unregistered: string[] = [];
|
||||||
|
const deleted: string[] = [];
|
||||||
|
|
||||||
|
await repairAppShell({
|
||||||
|
async registrations() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
unregister: async () => {
|
||||||
|
unregistered.push("installed");
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
async cacheNames() {
|
||||||
|
return ["workbox-precache-v2-https://karten.neitzel.de/", "kacheln", "workbox-runtime"];
|
||||||
|
},
|
||||||
|
async deleteCache(name) {
|
||||||
|
deleted.push(name);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(unregistered).toEqual(["installed"]);
|
||||||
|
expect(deleted).toEqual([
|
||||||
|
"workbox-precache-v2-https://karten.neitzel.de/",
|
||||||
|
"workbox-runtime",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes the Kachel cache on its own", async () => {
|
||||||
|
const deleted: string[] = [];
|
||||||
|
await deleteKachelCache({
|
||||||
|
async deleteCache(name) {
|
||||||
|
deleted.push(name);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(deleted).toEqual(["kacheln"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remembers a shell repair until the Karte opens", () => {
|
||||||
|
const store = memoryFlags();
|
||||||
|
expect(shellRepairWasTried(store)).toBe(false);
|
||||||
|
markShellRepairTried(store);
|
||||||
|
expect(shellRepairWasTried(store)).toBe(true);
|
||||||
|
clearShellRepairTried(store);
|
||||||
|
expect(shellRepairWasTried(store)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the failure the open threw", () => {
|
||||||
|
expect(bootFailureText(new Error("Übersicht fehlt"))).toBe("Übersicht fehlt");
|
||||||
|
expect(bootFailureText(new Error(" "))).toBe("Unbekannter Fehler");
|
||||||
|
expect(bootFailureText("kaputt")).toBe("Unbekannter Fehler");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { kachelCacheName } from "./cache-storage";
|
||||||
|
|
||||||
|
const SHELL_REPAIR_TRIED = "karte-shell-repaired";
|
||||||
|
|
||||||
|
export type BootRepairPorts = {
|
||||||
|
registrations(): Promise<ReadonlyArray<{ unregister(): Promise<boolean> }>>;
|
||||||
|
cacheNames(): Promise<string[]>;
|
||||||
|
deleteCache(name: string): Promise<boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FlagStore = {
|
||||||
|
getItem(key: string): string | null;
|
||||||
|
setItem(key: string, value: string): void;
|
||||||
|
removeItem(key: string): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Drop the installed app shell. Kacheln stay in their own cache. */
|
||||||
|
export async function repairAppShell(ports: BootRepairPorts): Promise<void> {
|
||||||
|
const registrations = await ports.registrations();
|
||||||
|
await Promise.all(registrations.map((registration) => registration.unregister()));
|
||||||
|
const names = await ports.cacheNames();
|
||||||
|
await Promise.all(
|
||||||
|
names
|
||||||
|
.filter((name) => name !== kachelCacheName)
|
||||||
|
.map((name) => ports.deleteCache(name)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop every stored Kachel. The heruntergeladen marks stay. */
|
||||||
|
export async function deleteKachelCache(ports: Pick<BootRepairPorts, "deleteCache">): Promise<void> {
|
||||||
|
await ports.deleteCache(kachelCacheName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bootFailureText(error: unknown): string {
|
||||||
|
if (error instanceof Error && error.message.trim()) return error.message;
|
||||||
|
return "Unbekannter Fehler";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBrowserBootRepair(): BootRepairPorts {
|
||||||
|
return {
|
||||||
|
async registrations() {
|
||||||
|
if (!navigator.serviceWorker) return [];
|
||||||
|
return navigator.serviceWorker.getRegistrations();
|
||||||
|
},
|
||||||
|
cacheNames: () => caches.keys(),
|
||||||
|
deleteCache: (name) => caches.delete(name),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shellRepairWasTried(store: FlagStore | null = browserFlags()): boolean {
|
||||||
|
try {
|
||||||
|
return store?.getItem(SHELL_REPAIR_TRIED) === "1";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markShellRepairTried(store: FlagStore | null = browserFlags()): void {
|
||||||
|
try {
|
||||||
|
store?.setItem(SHELL_REPAIR_TRIED, "1");
|
||||||
|
} catch {
|
||||||
|
// sessionStorage can be blocked. The repair still runs.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearShellRepairTried(store: FlagStore | null = browserFlags()): void {
|
||||||
|
try {
|
||||||
|
store?.removeItem(SHELL_REPAIR_TRIED);
|
||||||
|
} catch {
|
||||||
|
// sessionStorage can be blocked.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function browserFlags(): FlagStore | null {
|
||||||
|
try {
|
||||||
|
if (typeof sessionStorage === "undefined") return null;
|
||||||
|
return sessionStorage;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-6
@@ -1,6 +1,7 @@
|
|||||||
import "ol/ol.css";
|
import "ol/ol.css";
|
||||||
import "./style.css";
|
import "./style.css";
|
||||||
import { registerSW } from "virtual:pwa-register";
|
import { registerSW } from "virtual:pwa-register";
|
||||||
|
import { clearShellRepairTried } from "./adapters/boot-repair";
|
||||||
import { createCacheStorage } from "./adapters/cache-storage";
|
import { createCacheStorage } from "./adapters/cache-storage";
|
||||||
import { createKachelFetch } from "./adapters/kachel-fetch";
|
import { createKachelFetch } from "./adapters/kachel-fetch";
|
||||||
import { createMarksDb } from "./adapters/marks-db";
|
import { createMarksDb } from "./adapters/marks-db";
|
||||||
@@ -8,6 +9,7 @@ import { toEtrs, watchStandort } from "./adapters/standort";
|
|||||||
import { shippedConfig } from "./config";
|
import { shippedConfig } from "./config";
|
||||||
import { openKarte } from "./karte/session";
|
import { openKarte } from "./karte/session";
|
||||||
import type { Point, Uebersicht } from "./karte/types";
|
import type { Point, Uebersicht } from "./karte/types";
|
||||||
|
import { showBootError } from "./ui/boot-error";
|
||||||
import { mountScreen } from "./ui/screen";
|
import { mountScreen } from "./ui/screen";
|
||||||
|
|
||||||
void main();
|
void main();
|
||||||
@@ -40,13 +42,10 @@ async function main(): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
mountScreen(root, session, uebersicht);
|
mountScreen(root, session, uebersicht);
|
||||||
|
clearShellRepairTried();
|
||||||
bindDevice(session);
|
bindDevice(session);
|
||||||
} catch {
|
} catch (error) {
|
||||||
root.replaceChildren();
|
showBootError(root, error);
|
||||||
const message = document.createElement("p");
|
|
||||||
message.className = "boot-error";
|
|
||||||
message.textContent = "Die Karte konnte nicht geöffnet werden.";
|
|
||||||
root.append(message);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-1
@@ -231,11 +231,27 @@ button:disabled {
|
|||||||
color: #e7e1d4;
|
color: #e7e1d4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.boot-error {
|
.boot-failure {
|
||||||
margin: 24px 16px;
|
margin: 24px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 12px;
|
||||||
|
max-width: 28rem;
|
||||||
color: #e7e1d4;
|
color: #e7e1d4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.boot-failure .boot-error,
|
||||||
|
.boot-failure p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boot-detail {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
[hidden] {
|
[hidden] {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import {
|
||||||
|
bootFailureText,
|
||||||
|
createBrowserBootRepair,
|
||||||
|
deleteKachelCache,
|
||||||
|
markShellRepairTried,
|
||||||
|
repairAppShell,
|
||||||
|
shellRepairWasTried,
|
||||||
|
} from "../adapters/boot-repair";
|
||||||
|
|
||||||
|
export function showBootError(root: HTMLElement, error: unknown): void {
|
||||||
|
root.replaceChildren();
|
||||||
|
|
||||||
|
const box = document.createElement("div");
|
||||||
|
box.className = "boot-failure";
|
||||||
|
box.setAttribute("role", "alert");
|
||||||
|
|
||||||
|
const heading = document.createElement("p");
|
||||||
|
heading.className = "boot-error";
|
||||||
|
heading.textContent = "Die Karte konnte nicht geöffnet werden.";
|
||||||
|
|
||||||
|
const detail = document.createElement("p");
|
||||||
|
detail.className = "boot-detail";
|
||||||
|
detail.textContent = bootFailureText(error);
|
||||||
|
|
||||||
|
const keep = document.createElement("p");
|
||||||
|
keep.textContent = "Die gespeicherten Kacheln bleiben auf dem Gerät.";
|
||||||
|
|
||||||
|
const repair = document.createElement("button");
|
||||||
|
repair.type = "button";
|
||||||
|
repair.className = "secondary";
|
||||||
|
repair.textContent = "Karte neu starten";
|
||||||
|
|
||||||
|
box.append(heading, detail, keep, repair);
|
||||||
|
|
||||||
|
let clearKacheln: HTMLButtonElement | null = null;
|
||||||
|
if (shellRepairWasTried()) {
|
||||||
|
clearKacheln = document.createElement("button");
|
||||||
|
clearKacheln.type = "button";
|
||||||
|
clearKacheln.className = "secondary";
|
||||||
|
clearKacheln.textContent = "Gespeicherte Kacheln löschen";
|
||||||
|
box.append(clearKacheln);
|
||||||
|
}
|
||||||
|
|
||||||
|
root.append(box);
|
||||||
|
|
||||||
|
const ports = createBrowserBootRepair();
|
||||||
|
const buttons = () => [repair, clearKacheln].filter((button) => button !== null);
|
||||||
|
|
||||||
|
repair.addEventListener("click", () => {
|
||||||
|
void run(async () => {
|
||||||
|
markShellRepairTried();
|
||||||
|
await repairAppShell(ports);
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
clearKacheln?.addEventListener("click", () => {
|
||||||
|
void run(async () => {
|
||||||
|
await repairAppShell(ports);
|
||||||
|
await deleteKachelCache(ports);
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function run(action: () => Promise<void>): Promise<void> {
|
||||||
|
for (const button of buttons()) button.disabled = true;
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
} catch (failure) {
|
||||||
|
detail.textContent = bootFailureText(failure);
|
||||||
|
for (const button of buttons()) button.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user