Merge pull request #95 from kristianwind/fladen-foelger-udgaven
feat(udgaver): fladen følger udgaven, og søgningen holder opgaverne inde
This commit is contained in:
@@ -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/
|
||||
|
||||
+11
-1
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
+83
-36
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/<id>` og `/filter/<id>` 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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user