diff --git a/.gitignore b/.gitignore index 50cfe52..a9474b7 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,11 @@ node_modules.nosync/ # End-to-end runs: the throwaway instance the suite starts, the saved session it # signs in with, and whatever a failure left behind. /web/.playwright-data/ +# Noter-udgavens egen instans i prøvekørslen. Den har sin egen database OG sin +# egen secret.key, og den nåede at blive committet én gang: et mønster, der +# nævner den ene mappe ved navn, dækker ikke den anden, selv om de er to kopier +# af det samme. +/web/.playwright-data-notes/ /web/e2e/.auth/ /web/test-results/ /web/playwright-report/ diff --git a/docs/openapi.yaml b/docs/openapi.yaml index c55c14d..0e24d10 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -53,7 +53,7 @@ paths: /api/v1/auth/setup: get: tags: [auth] - summary: Whether this instance still needs its first account. + summary: What this instance is, and whether it still needs its first account. security: [] responses: "200": @@ -64,6 +64,16 @@ paths: type: object properties: needs_setup: { type: boolean } + edition: + type: string + enum: [full, notes] + description: > + `full` is tasks, projects and notes together. `notes` is + the notes half: the task routes are not mounted at all, so + an instance that is not a task manager cannot be asked to + be one through its API. The interface reads this before + anybody is signed in, because it decides what there is to + show rather than what this person may see. post: tags: [auth] summary: Create the first account, which becomes the administrator. diff --git a/internal/httpapi/auth_handlers.go b/internal/httpapi/auth_handlers.go index c03a230..94e6f9c 100644 --- a/internal/httpapi/auth_handlers.go +++ b/internal/httpapi/auth_handlers.go @@ -492,7 +492,14 @@ func (s *Server) handleSetupState(w http.ResponseWriter, r *http.Request) { s.internal(w, r, "count users", err) return } - writeJSON(w, http.StatusOK, map[string]bool{"needs_setup": n == 0}) + // Udgaven hører til her og ikke på /auth/me: den er en egenskab ved + // instansen, ikke ved den, der er logget ind, og fladen skal kende den, før + // nogen er. Det her er i forvejen kaldet, der spørger "hvad er det her for en + // instans", så den følger med uden en ekstra tur. + writeJSON(w, http.StatusOK, struct { + NeedsSetup bool `json:"needs_setup"` + Edition string `json:"edition"` + }{n == 0, s.edition()}) } func (s *Server) startSession(w http.ResponseWriter, r *http.Request, user *store.User) { diff --git a/internal/httpapi/edition.go b/internal/httpapi/edition.go new file mode 100644 index 0000000..a1043ee --- /dev/null +++ b/internal/httpapi/edition.go @@ -0,0 +1,16 @@ +package httpapi + +import "github.com/kristianwind/verdande/internal/config" + +// edition is what this instance tells the world it is. +// +// Never the empty string, even when the config was built by hand in a test and +// never went through config.Load: a flade that reads "" cannot tell "this is the +// full program" from "this build forgot to say", and the first of those is the +// answer that has always been true. +func (s *Server) edition() string { + if s.cfg.NotesOnly() { + return config.EditionNotes + } + return config.EditionFull +} diff --git a/internal/httpapi/edition_test.go b/internal/httpapi/edition_test.go index 3b69a67..39bcc78 100644 --- a/internal/httpapi/edition_test.go +++ b/internal/httpapi/edition_test.go @@ -1,6 +1,7 @@ package httpapi import ( + "context" "io" "log/slog" "net/http" @@ -158,3 +159,72 @@ func routesOf(t *testing.T, edition string) map[string]bool { } return out } + +// The interface cannot gate itself on something it is not told, and it has to be +// told before anybody is signed in — an instance's shape is not a permission. +func TestSetupStateSaysWhichEdition(t *testing.T) { + for _, tc := range []struct{ edition, want string }{ + {config.EditionFull, "full"}, + {config.EditionNotes, "notes"}, + {"", "full"}, // a config built by hand is the program as it has always been + } { + ts := newTestServerWith(t, func(c *config.Config) { c.Edition = tc.edition }) + + _, body := ts.do(t, "GET", "/api/v1/auth/setup", nil) + if got := body["edition"]; got != tc.want { + t.Errorf("edition %q reported as %v, want %q", tc.edition, got, tc.want) + } + // And the field it has always had is still there: adding one must not + // quietly replace the answer the sign-in screen depends on. + if _, ok := body["needs_setup"]; !ok { + t.Errorf("edition %q: needs_setup is gone from the answer", tc.edition) + } + } +} + +// Search is mounted in both editions — notes and notebooks have to be findable — +// so it is the one door that could still open onto the half the router just shut. +// +// Not a theoretical case. An instance converted from a full one still has the +// tasks in its database, and ⌘K would be the only place they turned up. +func TestNotesEditionSearchFindsNoTasks(t *testing.T) { + for _, tc := range []struct { + edition string + want int + }{ + {config.EditionFull, 1}, + {config.EditionNotes, 0}, + } { + ts := newTestServerWith(t, func(c *config.Config) { c.Edition = tc.edition }) + ts.bootstrap(t) + + _, me := ts.do(t, "GET", "/api/v1/auth/me", nil) + userID, _ := me["id"].(string) + if userID == "" { + t.Fatalf("no user: %v", me) + } + + // Created through the store rather than the API: in the notes edition there + // is no route to create one, and the row this is about is the row that is + // already there. + inbox, err := ts.db.InboxID(context.Background(), userID) + if err != nil { + t.Fatal(err) + } + task := &store.Task{ProjectID: inbox, Content: "Kvartalsregnskabet", CreatedBy: userID} + if err := ts.db.CreateTask(context.Background(), task, nil); err != nil { + t.Fatal(err) + } + + _, body := ts.do(t, "GET", "/api/v1/search?q=Kvartalsregnskabet", nil) + found, _ := body["tasks"].([]any) + if len(found) != tc.want { + t.Errorf("edition %q: search returned %d tasks, want %d", tc.edition, len(found), tc.want) + } + // The full edition's number is the control: a search that finds nothing at + // all would satisfy the notes case for the wrong reason. + if tc.edition == config.EditionFull && len(found) == 0 { + t.Errorf("the full edition found no tasks either — this proves nothing about the notes one") + } + } +} diff --git a/internal/httpapi/views_handlers.go b/internal/httpapi/views_handlers.go index 20c339c..6aaa023 100644 --- a/internal/httpapi/views_handlers.go +++ b/internal/httpapi/views_handlers.go @@ -206,13 +206,22 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { return } - tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{ - Search: query, - // Completed tasks are included: "what was that thing I did last week" is - // a search, and excluding them makes the box feel broken. - IncludeCompleted: true, - Limit: parseLimit(r.URL.Query().Get("limit"), 40, 100), - }) + // Søgningen er monteret i begge udgaver, fordi man også skal kunne finde sine + // noter og notesbøger. Men opgaverne må ikke komme med i noter-udgaven, og det + // er ikke en teoretisk sag: en instans lavet om fra en fuld har opgaverne + // liggende i databasen endnu, og så ville ⌘K være den ene dør, der stod åben + // ind til den halvdel, resten af programmet lige har lukket. + var tasks []store.Task + var err error + if !s.cfg.NotesOnly() { + tasks, err = s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{ + Search: query, + // Completed tasks are included: "what was that thing I did last week" is + // a search, and excluding them makes the box feel broken. + IncludeCompleted: true, + Limit: parseLimit(r.URL.Query().Get("limit"), 40, 100), + }) + } if err != nil { s.internal(w, r, "search tasks", err) return diff --git a/web/e2e/edition.spec.js b/web/e2e/edition.spec.js new file mode 100644 index 0000000..b8b9a09 --- /dev/null +++ b/web/e2e/edition.spec.js @@ -0,0 +1,120 @@ +import { test, expect } from '@playwright/test'; + +/** + * Noter-udgaven, kørt som sit eget program. + * + * Egen server på sin egen port med `VERDANDE_EDITION=notes`, egen database og + * ingen lånt session: den her instans har aldrig set den konto, den fulde udgaves + * opsætning lavede, så den laver sin egen. + * + * Og det er hele grunden til at prøven findes som en separat server frem for som + * en indstilling, en prøve skruer på. En skakt, der kun er prøvet ved at kalde en + * funktion, er prøvet på det, nogen mente — det her måler programmet, som en + * bruger møder det. + */ + +const KONTO = { + email: 'noter@example.dk', + name: 'Noteskriver', + password: 'et langt kodeord til test' +}; + +test.beforeEach(async ({ page }) => { + await page.goto('/'); + // Første konto, hvis den ikke er lavet endnu. Prøverne i filen deler instans, + // så den anden gang er det en almindelig login. + const opret = page.getByRole('button', { name: 'Opret konto' }); + const login = page.getByRole('button', { name: 'Log ind' }); + // Ventet på, at EN af de to er der, før der spørges hvilken. Spurgt før + // tegningen svarer begge nej, og så faldt prøven i den forkerte gren og ventede + // tredive sekunder på en knap, der aldrig kom. + await expect(opret.or(login)).toBeVisible(); + + if (await opret.isVisible()) { + await page.getByLabel(/Navn/).fill(KONTO.name); + await page.getByLabel(/E-mail/).fill(KONTO.email); + await page.getByLabel(/Adgangskode/).fill(KONTO.password); + await opret.click(); + // Beaconens besked står øverst første gang; den er ikke det her prøvens + // ærinde, men den dækker for det, der er. + const besked = page.getByRole('region', { name: /melder, at den findes/ }); + if (await besked.isVisible().catch(() => false)) { + await besked.getByRole('button', { name: 'Behold den' }).click(); + } + } else { + await page.getByLabel(/E-mail/).fill(KONTO.email); + await page.getByLabel(/Adgangskode/).fill(KONTO.password); + await page.getByRole('button', { name: 'Log ind' }).click(); + } + await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible(); +}); + +test('instansen siger selv, at den er noter-udgaven', async ({ page }) => { + // Fladen kan ikke lukke af for noget, den ikke får at vide, og den skal have + // det at vide, før nogen er logget ind. + const svar = await page.request.get('/api/v1/auth/setup'); + expect((await svar.json()).edition).toBe('notes'); +}); + +test('der er ingen vej til opgaverne i menuen', async ({ page }) => { + const menu = page.getByRole('navigation', { name: 'Hovedmenu' }); + await expect(menu).toBeVisible(); + + // Udeladt, ikke gråtonet: ruterne bag dem er ikke monteret på serveren. + for (const navn of ['I dag', 'Kommende', 'Kalender', 'Uddelegeret']) { + await expect(menu.getByRole('link', { name: navn })).toHaveCount(0); + } + + // Og det, udgaven ER til, står der. En menu uden opgaver OG uden noter ville + // også bestå prøven ovenfor, og den ville være en sletning frem for en skakt. + await expect(menu.getByRole('link', { name: 'Noter', exact: true })).toBeVisible(); +}); + +test('forsiden er noterne, ikke en tom opgaveliste', async ({ page }) => { + await page.goto('/'); + await expect(page).toHaveURL(/\/noter$/); +}); + +test('en gemt adresse til en opgaveside fører til noterne i stedet for et blindt spor', async ({ + page +}) => { + // Nogen, der har gemt et link fra en fuld instans, skal lande et sted, der + // giver mening. Der er ikke sket noget forkert — adressen hører bare til et + // andet program end det, der kører her. + for (const sti of ['/upcoming', '/kalender', '/uddelegeret', '/faerdige']) { + await page.goto(sti); + await expect(page).toHaveURL(/\/noter$/); + } +}); + +test('⌘K finder noter og ikke opgaver', async ({ page }) => { + await page.goto('/noter'); + await page.getByRole('button', { name: 'Ny note' }).click(); + const felt = page.getByRole('textbox', { name: 'Notens tekst' }); + await expect(felt).toHaveText(''); + await felt.click(); + await page.keyboard.type('Kvartalsregnskabet'); + await felt.blur(); + await expect(page.locator('.notes button.row strong').filter({ hasText: /^Kvartalsregnskabet$/ })).toHaveCount(1); + + await page.getByRole('button', { name: /Søg/ }).click(); + const palette = page.getByRole('dialog'); + await palette.getByRole('textbox').fill('Kvartalsregnskabet'); + + // Noten findes — ellers beviser det næste ingenting. + await expect(palette.locator('li button', { hasText: 'Kvartalsregnskabet' })).toBeVisible(); + // Og søgningen på serveren svarer uden opgaver overhovedet. + const svar = await page.request.get('/api/v1/search?q=Kvartalsregnskabet'); + expect((await svar.json()).tasks ?? []).toEqual([]); +}); + +test('opgaveruterne findes slet ikke på serveren', async ({ page }) => { + // Det her er forskellen på skjult og fraværende, målt udefra. + for (const sti of ['/api/v1/tasks', '/api/v1/labels', '/api/v1/upcoming', '/api/v1/filters']) { + const svar = await page.request.get(sti); + expect(svar.status(), `${sti} svarede ${svar.status()}`).toBe(404); + } + // Kontrollen: en rute, der ER monteret, svarer ikke 404 — ellers ville + // ovenstående bestå på en server, der var helt væk. + expect((await page.request.get('/api/v1/notes')).status()).toBe(200); +}); diff --git a/web/playwright.config.js b/web/playwright.config.js index 2994396..becdde0 100644 --- a/web/playwright.config.js +++ b/web/playwright.config.js @@ -20,6 +20,7 @@ import { dirname, join } from 'node:path'; const here = dirname(fileURLToPath(import.meta.url)); const repo = join(here, '..'); const dataDir = join(here, '.playwright-data'); +const notesDataDir = join(here, '.playwright-data-notes'); // A clean instance every run: the first test creates the first account, which only // works on an empty database. @@ -31,9 +32,14 @@ const dataDir = join(here, '.playwright-data'); // connection fail, with SQLITE_CANTOPEN. It looks exactly like a flaky auth bug. if (process.env.TEST_WORKER_INDEX === undefined) { rmSync(dataDir, { recursive: true, force: true }); + rmSync(notesDataDir, { recursive: true, force: true }); } const PORT = 8097; +// Noter-udgaven kører som sin egen server ved siden af den fulde. Den kan ikke +// dele port, og den må ikke dele database: de to er to instanser, og en prøve der +// lod dem skrive i den samme ville måle en tilstand, ingen af dem ejer. +const NOTES_PORT = 8098; // `localhost`, not `127.0.0.1`. WebAuthn refuses an IP address as a relying party // id — `SecurityError: This is an invalid domain` — and localhost is the exception // every browser makes. The passkey test cannot run against a loopback address. @@ -72,6 +78,10 @@ export default defineConfig({ { name: 'setup', testMatch: /.*\.setup\.js/ }, { name: 'chromium', + // Alt undtagen noter-udgaven, som har sin egen server på sin egen port. + // Uden den her linje kørte den prøve også her — mod den FULDE udgave, hvor + // den naturligvis fejler, og hvor fejlen læser som om skakten ikke virker. + testIgnore: /edition\.spec\.js/, use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/user.json' }, dependencies: ['setup'] }, @@ -95,6 +105,21 @@ export default defineConfig({ * * WebKit here is the same engine Safari is, which is the whole point. */ + /** + * Noter-udgaven, som sit eget program. + * + * Egen server, egen port, egen database, og ingen `storageState`: den her + * instans har aldrig set den konto, den fulde udgaves opsætning lavede, og + * at låne dens cookie ville prøve to instanser som var de én. + * + * Den deler ikke `setup` af samme grund — den laver sin egen første konto. + */ + { + name: 'notes-edition', + testMatch: /edition\.spec\.js/, + use: { ...devices['Desktop Chrome'], baseURL: `http://127.0.0.1:${NOTES_PORT}` } + }, + { name: 'webkit-forms', grep: /@forms/, @@ -103,42 +128,64 @@ export default defineConfig({ } ], - webServer: { - // The whole chain, every run. - // - // The frontend is embedded in the binary at compile time, from a copy under - // cmd/verdande/webbuild — so a suite that merely started the server would - // test whatever frontend was copied there last, which during development is - // nearly always the wrong one. Testing a stale build is worse than not - // testing: it reports green on code that is not running. - command: [ - 'npm run build', - 'rm -rf ../cmd/verdande/webbuild', - 'cp -r build ../cmd/verdande/webbuild', - 'cd .. && go run -tags embedweb ./cmd/verdande' - ].join(' && '), - cwd: here, - url: `${baseURL}/healthz`, - reuseExistingServer: false, - // The first run compiles the binary and builds nothing else; a cold Go - // build on a CI runner is comfortably under two minutes. - timeout: 180_000, - stdout: 'pipe', - stderr: 'pipe', - env: { - VERDANDE_ADDR: `:${PORT}`, - // `localhost`, while the suite itself drives `127.0.0.1`. These are two - // different things: the suite's address is where the browser goes, and - // this one is what the server calls itself — which is now also the - // WebAuthn relying party id, and WebAuthn refuses an IP address. + webServer: [ + { + // The whole chain, every run. // - // The passkey test navigates to localhost explicitly so its origin - // matches. Everything else stays on the loopback address, where it has - // always been. - VERDANDE_BASE_URL: `http://localhost:${PORT}`, - VERDANDE_DATA_DIR: dataDir, - // Off: an update check would reach out to GitHub from a test run. - VERDANDE_UPDATE_CHECK: 'false' + // The frontend is embedded in the binary at compile time, from a copy under + // cmd/verdande/webbuild — so a suite that merely started the server would + // test whatever frontend was copied there last, which during development is + // nearly always the wrong one. Testing a stale build is worse than not + // testing: it reports green on code that is not running. + command: [ + 'npm run build', + 'rm -rf ../cmd/verdande/webbuild', + 'cp -r build ../cmd/verdande/webbuild', + 'cd .. && go run -tags embedweb ./cmd/verdande' + ].join(' && '), + cwd: here, + url: `${baseURL}/healthz`, + reuseExistingServer: false, + // The first run compiles the binary and builds nothing else; a cold Go + // build on a CI runner is comfortably under two minutes. + timeout: 180_000, + stdout: 'pipe', + stderr: 'pipe', + env: { + VERDANDE_ADDR: `:${PORT}`, + // `localhost`, while the suite itself drives `127.0.0.1`. These are two + // different things: the suite's address is where the browser goes, and + // this one is what the server calls itself — which is now also the + // WebAuthn relying party id, and WebAuthn refuses an IP address. + // + // The passkey test navigates to localhost explicitly so its origin + // matches. Everything else stays on the loopback address, where it has + // always been. + VERDANDE_BASE_URL: `http://localhost:${PORT}`, + VERDANDE_DATA_DIR: dataDir, + // Off: an update check would reach out to GitHub from a test run. + VERDANDE_UPDATE_CHECK: 'false' + } + }, + + // Den samme binær en gang til, med den anden udgave slået til. Bygningen er + // allerede lavet af serveren ovenfor — `go run` genbruger byggecachen — så + // det her koster en opstart og ikke en oversættelse. + { + command: 'cd .. && go run -tags embedweb ./cmd/verdande', + cwd: here, + url: `http://127.0.0.1:${NOTES_PORT}/healthz`, + reuseExistingServer: false, + timeout: 180_000, + stdout: 'pipe', + stderr: 'pipe', + env: { + VERDANDE_ADDR: `:${NOTES_PORT}`, + VERDANDE_BASE_URL: `http://localhost:${NOTES_PORT}`, + VERDANDE_DATA_DIR: notesDataDir, + VERDANDE_EDITION: 'notes', + VERDANDE_UPDATE_CHECK: 'false' + } } - } + ] }); diff --git a/web/src/lib/components/CommandPalette.svelte b/web/src/lib/components/CommandPalette.svelte index 0d62180..187ae13 100644 --- a/web/src/lib/components/CommandPalette.svelte +++ b/web/src/lib/components/CommandPalette.svelte @@ -85,7 +85,11 @@ ...settings.map((s) => ({ kind: 'setting', id: s.href, label: `${s.tab} · ${s.title}` })), ...projects.map((p) => ({ kind: 'project', id: p.id, label: p.name })), ...notes.map((n) => ({ kind: 'note', id: n.id, label: n.title || n.body.slice(0, 60) })), - ...tasks.map((t) => ({ + // Serveren sender ingen opgaver i noter-udgaven, så listen er alligevel tom. + // Spurgt her også, fordi en tom liste af den rigtige grund og en tom liste + // ved et tilfælde ser ens ud — og den dag nogen mounter søgningen anderledes, + // skal ⌘K ikke være stedet, det viser sig. + ...(app.notesOnly ? [] : tasks).map((t) => ({ kind: 'task', id: t.id, label: t.content, diff --git a/web/src/lib/components/Sidebar.svelte b/web/src/lib/components/Sidebar.svelte index 4f0167c..5dc4d2b 100644 --- a/web/src/lib/components/Sidebar.svelte +++ b/web/src/lib/components/Sidebar.svelte @@ -155,9 +155,12 @@ // entry is a question the sidebar keeps asking and answering "no". let navItems = $derived( [ - { key: 'today', href: '/', label: 'nav.today' }, - { key: 'upcoming', href: '/upcoming', label: 'nav.upcoming' }, - app.projects.some((p) => p.shared) + // Opgavernes egne indgange findes ikke i noter-udgaven. Ikke gråtonet og + // ikke skjult med CSS — udeladt, fordi ruterne bag dem ikke er monteret + // på serveren. Et link til noget, der ikke findes, er ikke en skønhedsfejl. + app.notesOnly ? null : { key: 'today', href: '/', label: 'nav.today' }, + app.notesOnly ? null : { key: 'upcoming', href: '/upcoming', label: 'nav.upcoming' }, + !app.notesOnly && app.projects.some((p) => p.shared) ? { key: 'delegated', href: '/uddelegeret', label: 'nav.delegated' } : null, // No href: the inbox is a project and is drawn by projectRow, which knows @@ -185,7 +188,7 @@ // Sidst i rækken, fordi `navOrder` føjer en ukendt nøgle til bagest: står // den et andet sted her, ser en ny konto én rækkefølge og alle // eksisterende en anden. - { key: 'calendar', href: '/kalender', label: 'nav.calendar' } + app.notesOnly ? null : { key: 'calendar', href: '/kalender', label: 'nav.calendar' } ].filter(Boolean) ); diff --git a/web/src/lib/stores.svelte.js b/web/src/lib/stores.svelte.js index 513851f..4af96a1 100644 --- a/web/src/lib/stores.svelte.js +++ b/web/src/lib/stores.svelte.js @@ -43,6 +43,27 @@ class AppState { */ unreachable = $state(false); + /** + * Hvilken udgave af programmet den her instans er. + * + * `full` er opgaver, projekter og noter sammen. `notes` er notehalvdelen, hvor + * opgaveruterne slet ikke er monteret på serveren — så fladen må ikke tilbyde + * en vej til dem. Et link til en rute, der ikke findes, er ikke en skønhedsfejl: + * det er en knap, der fører et sted hen, hvor der ikke er noget. + * + * Instansens form, ikke personens rettigheder. Derfor hentes den ved siden af + * kontoen frem for efter den: den skal også være kendt, når ingen er logget ind. + * + * `full` som udgangspunkt, fordi det er det, programmet altid har været — en + * instans, der ikke når at svare, skal ikke pludselig se ud som et andet + * program. + */ + edition = $state('full'); + + get notesOnly() { + return this.edition === 'notes'; + } + /** Transient messages: a failed save, a rolled-back change. */ toasts = $state([]); connected = $state(false); @@ -108,6 +129,17 @@ class AppState { async load() { this.loading = true; + + // Formen hentes for sig, og en fejl på den tier. En 401 fra `me` må ikke + // tage udgaven med sig — login-skærmen er også en flade i en udgave — og + // en instans, der slet ikke svarer, melder `me` ordentligt nedenfor. + const shape = api + .setupState() + .then((state) => { + if (state?.edition) this.edition = state.edition; + }) + .catch(() => {}); + try { this.user = await api.me(); this.unreachable = false; @@ -131,6 +163,10 @@ class AppState { // arriving. A 401 is the server answering, and that is a real sign-out. this.unreachable = e?.code === 'offline'; } finally { + // Afventet her, så fladen aldrig tegnes, før den ved hvilket program den + // er. Tegnet først og rettet bagefter ville vise opgaver i et sekund på + // en instans, der ikke har nogen. + await shape; this.loading = false; } } diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index b311528..02dde93 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -57,6 +57,39 @@ app.load(); }); + /** + * Opgavernes sider findes ikke i noter-udgaven. + * + * Ét sted frem for en vagt i hver rute: listen ER beslutningen, og en vagt pr. + * fil er syv steder at glemme den ottende. Skrevet som præfikser, fordi + * `/opgave/` og `/filter/` er sider under dem. + * + * Sendt til noterne og ikke til en fejlside: der er ikke sket noget forkert. + * Adressen hører bare til et andet program end det, der kører her — og nogen, + * der har gemt et link fra en fuld instans, skal lande et sted, der giver + * mening frem for i en blindgyde. + * + * `replaceState`, så Tilbage ikke fører hen til den side, man lige blev sendt + * væk fra, og derfra tilbage igen. + */ + const TASK_PAGES = [ + '/', + '/upcoming', + '/uddelegeret', + '/kalender', + '/faerdige', + '/opgave', + '/filter', + '/etiket' + ]; + + $effect(() => { + if (!app.notesOnly) return; + const here = $page.url.pathname; + const taskPage = TASK_PAGES.some((p) => here === p || (p !== '/' && here.startsWith(p + '/'))); + if (taskPage) goto('/noter', { replaceState: true }); + }); + /** * Links ud af programmet, åbnet i den browser, maskinen er sat op til. *