From 3cee9bfb6e1aece393537bbd9c26371341ed3860 Mon Sep 17 00:00:00 2001 From: kristianwind Date: Tue, 6 Oct 2026 23:13:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(udgaver):=20fladen=20f=C3=B8lger=20udgaven?= =?UTF-8?q?,=20og=20s=C3=B8gningen=20holder=20opgaverne=20inde?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trin 2. Serveren kunne allerede lade være med at montere opgaveruterne; fladen tilbød dem stadig, så en noter-instans havde links til sider, der ikke fandtes. --- hvordan fladen får det at vide --- `/auth/setup` bærer nu `edition`. Dér og ikke på `/auth/me`: udgaven er en egenskab ved instansen, ikke ved den, der er logget ind, og fladen skal kende den, før nogen er. Det er i forvejen kaldet, der spørger "hvad er det her for en instans", så den følger med uden en ekstra tur. Butikken henter formen ved siden af kontoen og tier, hvis den fejler: en 401 fra `me` må ikke tage udgaven med sig — login-skærmen er også en flade i en udgave. Afventet før `loading` slippes, 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. `full` som udgangspunkt, fordi det er det, programmet altid har været. --- hvad der forsvinder --- Menuens opgaveindgange er udeladt, ikke gråtonet. Forsiden er noterne. En gemt adresse til /upcoming, /kalender, /uddelegeret, /faerdige, /opgave, /filter eller /etiket fører til noterne frem for en blindgyde — der er ikke sket noget forkert, adressen hører bare til et andet program end det, der kører her. Listen står ét sted i layoutet frem for som en vagt pr. rute: listen ER beslutningen, og en vagt pr. fil er syv steder at glemme den ottende. --- et hul, prøven fandt --- /search er monteret i begge udgaver, fordi noter og notesbøger skal kunne findes — og den returnerede opgaver. Ikke teoretisk: en instans lavet om fra en fuld har opgaverne liggende i databasen endnu, så ⌘K ville være den ene dør, der stod åben ind til den halvdel, resten af programmet lige havde lukket. Lukket på serveren, hvor det hører hjemme, og ⌘K spørger også selv — en tom liste af den rigtige grund og en tom liste ved et tilfælde ser ens ud. Prøven har en kontrol: den fulde udgave SKAL finde opgaven, ellers beviser nullet i noter-udgaven ingenting. --- prøverne --- Et nyt Playwright-projekt med sin egen server, sin egen port og sin egen database. Ingen lånt session: den instans har aldrig set den konto, den fulde udgaves opsætning lavede. 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. Seks prøver, heriblandt forskellen på skjult og fraværende målt udefra: /api/v1/tasks, /labels, /upcoming og /filters skal svare 404, og /api/v1/notes skal svare 200 — ellers ville de fire bestå på en server, der bare var væk. `testIgnore` på chromium-projektet, fordi filen ellers også kørte dér, mod den fulde udgave, hvor den fejler med beskeder der læser som om skakten ikke virker. Den rigtige fejl stod i projektnavnet, ikke i påstanden. Go-suiten grøn. chromium + notes-edition: 110 bestået, 0 fejlet. Co-Authored-By: Claude Opus 5 --- .gitignore | 5 + docs/openapi.yaml | 12 +- internal/httpapi/auth_handlers.go | 9 +- internal/httpapi/edition.go | 16 +++ internal/httpapi/edition_test.go | 70 +++++++++++ internal/httpapi/views_handlers.go | 23 ++-- web/e2e/edition.spec.js | 120 +++++++++++++++++++ web/playwright.config.js | 119 ++++++++++++------ web/src/lib/components/CommandPalette.svelte | 6 +- web/src/lib/components/Sidebar.svelte | 11 +- web/src/lib/stores.svelte.js | 36 ++++++ web/src/routes/+layout.svelte | 33 +++++ 12 files changed, 410 insertions(+), 50 deletions(-) create mode 100644 internal/httpapi/edition.go create mode 100644 web/e2e/edition.spec.js 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. *