Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08d703e3af | ||
|
|
d4784fd174 | ||
|
|
094ea5cdd8 | ||
|
|
f60758b770 |
+1
-1
@@ -108,7 +108,7 @@ curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
|
||||
| Method | Path | |
|
||||
|---|---|---|
|
||||
| `GET` | `/today` | Overdue and due today, in your timezone. |
|
||||
| `GET` | `/upcoming?days=7` | One entry per day, empty days included. |
|
||||
| `GET` | `/upcoming?days=7` | One entry per day, empty days included. Up to 31; over that is capped. |
|
||||
| `GET` | `/search?q=` | Across everything you can see. |
|
||||
| `GET` `POST` | `/filters` | |
|
||||
| `GET` | `/filters/{id}/tasks` | Run one. |
|
||||
|
||||
+6
-1
@@ -2365,7 +2365,12 @@ paths:
|
||||
tags: [views]
|
||||
summary: The next few days, empty ones included.
|
||||
parameters:
|
||||
- { name: days, in: query, schema: { type: integer, default: 7 } }
|
||||
- {
|
||||
name: days,
|
||||
in: query,
|
||||
description: "How many days to return, counting today. Above the maximum is capped, not refused.",
|
||||
schema: { type: integer, default: 7, minimum: 1, maximum: 31 },
|
||||
}
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
|
||||
@@ -285,6 +285,49 @@ func TestTodayAndUpcoming(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The horizon is the list's, and it is asked for per request: seven days is the
|
||||
// default, thirty is what Kommende offers for "when does this month get busy", and
|
||||
// anything past the cap is trimmed rather than refused — a client that asks for a
|
||||
// year gets a month, not a 400.
|
||||
func TestUpcomingHorizon(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, out := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "om tyve dage",
|
||||
"due_date": userDate(t, 20),
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create: %d %v", resp.StatusCode, out)
|
||||
}
|
||||
|
||||
count := func(query string) (int, int) {
|
||||
t.Helper()
|
||||
_, view := ts.do(t, "GET", "/api/v1/upcoming"+query, nil)
|
||||
days, _ := view["days"].([]any)
|
||||
var tasks int
|
||||
for _, d := range days {
|
||||
day, _ := d.(map[string]any)
|
||||
list, _ := day["tasks"].([]any)
|
||||
tasks += len(list)
|
||||
}
|
||||
return len(days), tasks
|
||||
}
|
||||
|
||||
if n, found := count(""); n != 7 || found != 0 {
|
||||
t.Errorf("default horizon returned %d days and %d tasks, want 7 and 0", n, found)
|
||||
}
|
||||
if n, found := count("?days=30"); n != 30 || found != 1 {
|
||||
t.Errorf("days=30 returned %d days and %d tasks, want 30 and 1", n, found)
|
||||
}
|
||||
if n, _ := count("?days=365"); n != 31 {
|
||||
t.Errorf("days=365 returned %d days, want it capped at 31", n)
|
||||
}
|
||||
if n, _ := count("?days=nonsense"); n != 7 {
|
||||
t.Errorf("an unreadable days returned %d days, want the default 7", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchFindsAcrossProjects(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
@@ -51,7 +51,8 @@ type upcomingDay struct {
|
||||
Tasks []taskJSON `json:"tasks"`
|
||||
}
|
||||
|
||||
// handleUpcoming returns the next seven days, one entry per day.
|
||||
// handleUpcoming returns the next `days` days, one entry per day, seven by default
|
||||
// and at most a month.
|
||||
//
|
||||
// Empty days are included on purpose: the view is a calendar strip, and a Thursday
|
||||
// missing from the response because nothing is due would collapse the layout and
|
||||
@@ -65,10 +66,14 @@ func (s *Server) handleUpcoming(w http.ResponseWriter, r *http.Request) {
|
||||
from := start.Format("2006-01-02")
|
||||
to := start.AddDate(0, 0, days-1).Format("2006-01-02")
|
||||
|
||||
// The row limit follows the window rather than sitting at a constant, or a
|
||||
// month asked for with the same ceiling as a week would truncate the last days
|
||||
// silently — and a day cut off the end of the answer looks exactly like a day
|
||||
// with nothing due.
|
||||
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
|
||||
DueFrom: from,
|
||||
DueBefore: to,
|
||||
Limit: 1000,
|
||||
Limit: 200 * days,
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, r, "upcoming", err)
|
||||
|
||||
@@ -64,6 +64,60 @@ function watchForTrouble(page) {
|
||||
return trouble;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kommendes liste kan se længere frem end en uge.
|
||||
*
|
||||
* Syv dage er stadig det, den åbner på — det er den vandring, der er værd at gå om
|
||||
* morgenen. Men "hvornår bliver den her måned travl" er et andet spørgsmål, og
|
||||
* månedsgitteret svarer på det med form frem for med rækkefølge. Derfor kan
|
||||
* listen sættes til 14 og 30 dage, og valget bliver liggende: horisonten er et
|
||||
* spørgsmål om, hvor langt man planlægger, ikke om hvilken skærm man sidder ved —
|
||||
* men det er den samme slags valg som gitter eller liste, så det ligger samme sted.
|
||||
*/
|
||||
test('Kommende kan se en måned frem, og horisonten huskes', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
const box = page.getByLabel('Ny opgave');
|
||||
await box.fill('skifte olie om 20 dage');
|
||||
await box.press('Enter');
|
||||
await expect(box).toHaveValue('');
|
||||
|
||||
await page.goto('/upcoming');
|
||||
await page.getByRole('button', { name: 'Liste', exact: true }).click();
|
||||
|
||||
const days = page.locator('section:has(> h2)');
|
||||
const task = page.getByText('skifte olie', { exact: true });
|
||||
|
||||
// Syv dage er udgangspunktet, og opgaven ligger uden for dem. At den er væk her
|
||||
// er halvdelen af prøven: uden den kunne de tredive dage nedenfor være syv, der
|
||||
// tilfældigvis viste den.
|
||||
await expect(days).toHaveCount(7);
|
||||
await expect(task).toBeHidden();
|
||||
|
||||
await page.getByRole('button', { name: '30 dage', exact: true }).click();
|
||||
await expect(days).toHaveCount(30);
|
||||
await expect(task).toBeVisible();
|
||||
|
||||
// Ugenummeret står kun, når strimlen er længere end en uge — ellers siger det
|
||||
// ingenting, man ikke kunne se i forvejen.
|
||||
await expect(days.first().getByText(/uge \d+/i)).toBeVisible();
|
||||
|
||||
// Og valget overlever en genindlæsning.
|
||||
await page.reload();
|
||||
await expect(days).toHaveCount(30);
|
||||
await expect(
|
||||
page.getByRole('button', { name: '30 dage', exact: true }),
|
||||
'horisonten blev ikke husket'
|
||||
).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
await page.getByRole('button', { name: '7 dage', exact: true }).click();
|
||||
await expect(days).toHaveCount(7);
|
||||
await expect(days.first().getByText(/uge \d+/i)).toBeHidden();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('hurtig tilføjelse opretter en opgave, og den kan lukkes', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
@@ -2784,6 +2838,142 @@ test('en note kan deles med en person direkte, og dukker op hos dem', async ({ b
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Note-links i en delt note.
|
||||
*
|
||||
* Meldt af en, der havde en note delt med sig: linkene i den gjorde ingenting.
|
||||
* Det var to ting. Opslaget faldt tilbage på det første søgeresultat, når titlen
|
||||
* ikke passede — og i en delt note er det første resultat næsten altid noten selv,
|
||||
* fordi den nævner den titel, den linker til. Så åbnede linket den note, man
|
||||
* allerede stod i, hvilket ser ud som ingenting. Og når det så endelig ikke fandt
|
||||
* noget, sagde beskeden "blev ikke fundet", som var den forkerte grund: noten
|
||||
* findes, den er bare ikke delt med.
|
||||
*/
|
||||
test('note links i en delt note fører til den rigtige note, eller siger hvorfor ikke', async ({
|
||||
browser,
|
||||
page
|
||||
}) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
|
||||
await page.goto('/indstillinger/brugere');
|
||||
await page.getByLabel('E-mailadresse').fill('link@example.dk');
|
||||
await page.getByRole('button', { name: 'Send invitation' }).click();
|
||||
const link = await page.locator('.link-out').textContent();
|
||||
|
||||
const gæstCtx = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||
const gæst = await gæstCtx.newPage();
|
||||
await gæst.goto(link);
|
||||
await gæst.getByLabel('Navn', { exact: true }).fill('Linnea');
|
||||
await gæst.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
||||
await gæst.getByRole('button', { name: 'Opret konto' }).click();
|
||||
await expect(gæst.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||
const noterURL = new URL('/noter', link).href;
|
||||
|
||||
// Ejeren skriver to noter: en, der bliver delt, og en, der ikke gør — og den
|
||||
// delte peger på den anden.
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
await page.getByRole('textbox', { name: 'Notens tekst' }).click();
|
||||
await page.keyboard.type('Prisliste for foraaret');
|
||||
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
|
||||
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
await page.getByRole('textbox', { name: 'Notens tekst' }).click();
|
||||
await page.keyboard.type('Aftale om levering\n');
|
||||
await page.keyboard.type('[[Prisliste for foraaret]]');
|
||||
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
|
||||
|
||||
// Gæsten har en note af sin egen, som hun kan pege på.
|
||||
await gæst.goto(noterURL);
|
||||
await gæst.getByRole('button', { name: 'Ny note' }).click();
|
||||
await gæst.getByRole('textbox', { name: 'Notens tekst' }).click();
|
||||
await gæst.keyboard.type('Linneas egne maal');
|
||||
await gæst.getByRole('textbox', { name: 'Notens tekst' }).blur();
|
||||
|
||||
await page.locator('.notes').getByRole('button', { name: /Aftale om levering/ }).click();
|
||||
await page.locator('.sharewrap > button').click();
|
||||
const share = page.locator('.sharepanel');
|
||||
await expect(share).toBeVisible();
|
||||
await share.locator('.addshare select').first().selectOption({ label: 'Linnea' });
|
||||
await share.getByRole('button', { name: 'Tilføj' }).click();
|
||||
const row = share.locator('.sharelist li').filter({ hasText: 'Linnea' });
|
||||
await expect(row).toBeVisible();
|
||||
await row.locator('select').selectOption('editor');
|
||||
|
||||
// Hun skriver sit eget link ind i den delte note.
|
||||
await gæst.goto(noterURL);
|
||||
await gæst.locator('.notes').getByRole('button', { name: /Aftale om levering/ }).click();
|
||||
const body = gæst.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await expect(body).toContainText('Prisliste for foraaret');
|
||||
await body.click();
|
||||
await gæst.keyboard.press('Control+End');
|
||||
await gæst.keyboard.type('\n[[Linneas egne maal]]');
|
||||
await body.blur();
|
||||
await expect(gæst.locator('footer .hint')).toHaveText('Gemt');
|
||||
|
||||
await gæst.goto(noterURL);
|
||||
await gæst.locator('.notes').getByRole('button', { name: /Aftale om levering/ }).click();
|
||||
const delt = gæst.getByRole('textbox', { name: 'Notens tekst' });
|
||||
|
||||
// Hendes eget link fører derhen, det siger.
|
||||
await delt.locator('a.notelink', { hasText: 'Linneas egne maal' }).click();
|
||||
await expect(gæst.getByRole('textbox', { name: 'Notens tekst' })).toContainText(
|
||||
'Linneas egne maal'
|
||||
);
|
||||
|
||||
// Ejerens link peger på en note, hun ikke har fået. Den må ikke lande et
|
||||
// tilfældigt sted — og den må sige hvorfor.
|
||||
await gæst.locator('.notes').getByRole('button', { name: /Aftale om levering/ }).click();
|
||||
const igen = gæst.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await expect(igen).toContainText('Prisliste for foraaret');
|
||||
await igen.locator('a.notelink', { hasText: 'Prisliste for foraaret' }).click();
|
||||
await expect(gæst.locator('.toast')).toContainText('ikke delt med dig');
|
||||
// Stadig i den delte note: linket flyttede hende ingen steder hen.
|
||||
await expect(gæst.getByRole('textbox', { name: 'Notens tekst' })).toContainText(
|
||||
'Aftale om levering'
|
||||
);
|
||||
|
||||
await gæstCtx.close();
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* `</>` er kode, ikke kilde.
|
||||
*
|
||||
* Sådan blev den læst af den første, der så den — markér et ord, tryk på tegnet,
|
||||
* og hele noten skiftede til Markdown. Tegnet betyder kode i ethvert andet
|
||||
* program, så det er knappen, der skal give efter, ikke brugeren.
|
||||
*/
|
||||
test('</> sætter markeringen som kode, og kilden har sin egen knap', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
const body = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await body.click();
|
||||
await page.keyboard.type('Kodenote\n');
|
||||
await page.keyboard.type('kør kommandoen npm først');
|
||||
|
||||
// Markér ét ord og gør det til kode.
|
||||
await page.keyboard.press('Shift+Home');
|
||||
await page.keyboard.press('Shift+ArrowRight');
|
||||
await page.getByRole('button', { name: 'Kode', exact: true }).click();
|
||||
await expect(body.locator('code')).toHaveCount(1);
|
||||
|
||||
// Noten er stadig rig tekst — kildevisningen er ikke slået til.
|
||||
await expect(page.locator('textarea.source')).toHaveCount(0);
|
||||
|
||||
// Og den anden knap gør stadig det, den altid har gjort: viser filen, med
|
||||
// baktikkerne i, som er beviset på at koden nåede hele vejen ned.
|
||||
await body.blur();
|
||||
await expect(page.locator('footer .hint')).toHaveText('Gemt');
|
||||
await page.getByRole('button', { name: /Vis kilden/ }).click();
|
||||
await expect(page.locator('textarea.source')).toHaveValue(/`/);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('udseendet kan skiftes uafhængigt af temaet, og overlever en genindlæsning', async ({
|
||||
page
|
||||
}) => {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
@@ -164,6 +164,111 @@
|
||||
changed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline kode — `sådan her`.
|
||||
*
|
||||
* Den knap fandtes ikke, og `</>`, som i ethvert andet program betyder netop
|
||||
* kode, sad på kildevisningen med teksten "Vis som kode" på. Så markerede man et
|
||||
* ord og trykkede på den, og hele noten skiftede til Markdown. Det blev meldt
|
||||
* som en fejl, og det var det også: knappen sagde ét og gjorde et andet. Nu gør
|
||||
* `</>` det, tegnet siger, og kilden har fået sit eget mærke.
|
||||
*
|
||||
* Skrevet i hånden, fordi execCommand ikke har en kommando for kode. Markeringen
|
||||
* læses som ren tekst — det er hele meningen med kode, at det ikke er formateret
|
||||
* — og lægges tilbage inde i et <code>, som konverteringen allerede kender
|
||||
* begge veje.
|
||||
*/
|
||||
function codeAround() {
|
||||
const selection = window.getSelection();
|
||||
let node = selection?.anchorNode;
|
||||
while (node && node !== editor) {
|
||||
if (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'CODE') return node;
|
||||
node = node.parentNode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Markerer ordet, markøren står i. Bruges når der ikke er markeret noget. */
|
||||
function selectWordAtCaret() {
|
||||
const selection = window.getSelection();
|
||||
const node = selection?.anchorNode;
|
||||
if (!node || node.nodeType !== Node.TEXT_NODE) return;
|
||||
const text = node.textContent ?? '';
|
||||
let start = selection.anchorOffset;
|
||||
let end = start;
|
||||
while (start > 0 && !/\s/.test(text[start - 1])) start--;
|
||||
while (end < text.length && !/\s/.test(text[end])) end++;
|
||||
if (start === end) return;
|
||||
const range = document.createRange();
|
||||
range.setStart(node, start);
|
||||
range.setEnd(node, end);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
function selectContents(node) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(node);
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
function toggleCode() {
|
||||
editor?.focus();
|
||||
const selection = window.getSelection();
|
||||
if (!selection?.rangeCount) return;
|
||||
|
||||
// Inde i en kodeblok er alting allerede kode; en <code> derinde kan hverken
|
||||
// ses eller skrives ned, for en hegnet blok er tekst hele vejen igennem.
|
||||
const block = currentBlock();
|
||||
if (block?.tagName === 'PRE') return;
|
||||
|
||||
const existing = codeAround();
|
||||
if (existing) {
|
||||
// Ud igen: teksten bliver stående, mærkerne forsvinder. Markeringen lægges
|
||||
// om den, så et tryk mere sætter den tilbage — knappen er en kontakt.
|
||||
const text = document.createTextNode(existing.textContent ?? '');
|
||||
existing.replaceWith(text);
|
||||
selectContents(text);
|
||||
readState();
|
||||
changed();
|
||||
return;
|
||||
}
|
||||
|
||||
// Uden en markering er det ordet, markøren står i. Det er det, ⌘B gør i et
|
||||
// tekstprogram, og bedre end et tomt <code>, der ikke kan ses og ikke kan
|
||||
// skrives ned.
|
||||
if (selection.isCollapsed) selectWordAtCaret();
|
||||
const live = window.getSelection();
|
||||
const text = live.toString();
|
||||
if (!text) return;
|
||||
|
||||
// Bygget som en node frem for med `insertHTML`.
|
||||
//
|
||||
// Chromium renser den HTML, den får dér, gennem den samme vej som en
|
||||
// indsætning: `<code>` kom ud som et `<span>` med skrifttypen og farven
|
||||
// skrevet ind i sig, og et span er ikke noget, htmlToMarkdown kan skrive ned
|
||||
// som kode. Målt — mærket forsvandt hver gang, og noten så rigtig ud, indtil
|
||||
// den blev gemt.
|
||||
const range = live.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
const code = document.createElement('code');
|
||||
code.textContent = text;
|
||||
range.insertNode(code);
|
||||
|
||||
// Markøren efter mærket, ikke inde i det: ellers bliver det næste ord, man
|
||||
// skriver, også til kode.
|
||||
const after = document.createRange();
|
||||
after.setStartAfter(code);
|
||||
after.collapse(true);
|
||||
live.removeAllRanges();
|
||||
live.addRange(after);
|
||||
|
||||
readState();
|
||||
changed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Steps out of a code block on Enter.
|
||||
*
|
||||
@@ -223,7 +328,11 @@
|
||||
underline: document.queryCommandState('underline'),
|
||||
strike: document.queryCommandState('strikeThrough'),
|
||||
bullet: document.queryCommandState('insertUnorderedList'),
|
||||
numbered: document.queryCommandState('insertOrderedList')
|
||||
numbered: document.queryCommandState('insertOrderedList'),
|
||||
// Læst af DOM'en og ikke af execCommand: der findes ingen kommando
|
||||
// for kode, hvilket er hele grunden til at knappen er skrevet i
|
||||
// hånden herunder.
|
||||
code: !!codeAround()
|
||||
};
|
||||
} catch {
|
||||
// Some browsers throw on queryCommandState with no selection. The toolbar
|
||||
@@ -641,6 +750,9 @@
|
||||
if (key === 'u') {
|
||||
event.preventDefault();
|
||||
apply('underline');
|
||||
} else if (key === 'e') {
|
||||
event.preventDefault();
|
||||
toggleCode();
|
||||
} else if (key === 'x' && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
apply('strikeThrough');
|
||||
@@ -788,19 +900,50 @@
|
||||
<button class:on={active.strike} onclick={() => apply('strikeThrough')} aria-label={t('notes.strike')}>
|
||||
<s>S</s>
|
||||
</button>
|
||||
<!-- `</>` hører til her, mellem de andre mærker, fordi kode er et mærke som
|
||||
fed og kursiv. Den sad på kildevisningen, og det var en fælde: tegnet
|
||||
betyder kode overalt ellers, så folk markerede et ord og fik hele noten
|
||||
om til Markdown. -->
|
||||
<button
|
||||
class:on={active.code}
|
||||
onclick={toggleCode}
|
||||
title="⌘E"
|
||||
aria-pressed={!!active.code}
|
||||
aria-label={t('notes.code')}
|
||||
>
|
||||
<span class="codemark"></></span>
|
||||
</button>
|
||||
|
||||
<span class="sep" aria-hidden="true"></span>
|
||||
|
||||
<!-- Kilden. Til højre for de andre, fordi den ikke er et format men en
|
||||
anden måde at se det hele på. -->
|
||||
anden måde at se det hele på — og med Markdown-mærket på, som er præcis
|
||||
det, den viser. Et tegn, ingen forveksler med et format. -->
|
||||
<button
|
||||
class="src"
|
||||
class:on={asSource}
|
||||
onclick={toggleSource}
|
||||
aria-pressed={asSource}
|
||||
aria-label={t('notes.showSource')}
|
||||
title={t('notes.showSource')}></></button
|
||||
title={t('notes.showSource')}
|
||||
>
|
||||
<svg viewBox="0 0 208 128" aria-hidden="true">
|
||||
<rect
|
||||
x="5"
|
||||
y="5"
|
||||
width="198"
|
||||
height="118"
|
||||
rx="14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="12"
|
||||
/>
|
||||
<path
|
||||
d="M30 98V30h20l20 25 20-25h20v68H90V59L70 84 50 59v39zM155 98l-30-33h20V30h20v35h20z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if asSource}
|
||||
@@ -1132,11 +1275,24 @@
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.src {
|
||||
/* Kodeknappen bærer sit eget tegn i monospace — `</>` sat med brødskriften
|
||||
ligner en tastefejl frem for et mærke. */
|
||||
.codemark {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.src {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.src svg {
|
||||
width: 17px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* The document styles. These are the whole of "it should look like Apple Notes":
|
||||
a title that is a title, and body text that is comfortable to read for a page
|
||||
at a time. */
|
||||
|
||||
@@ -23,7 +23,7 @@ export const da = {
|
||||
// Deling med enkeltpersoner, ved siden af projektet.
|
||||
'notes.groupSharedWithMe': 'Delt med mig',
|
||||
'notes.linkOpen': 'Åbn',
|
||||
'notes.linkMissing': 'Noten blev ikke fundet.',
|
||||
'notes.linkMissing': 'Noten findes ikke, eller den er ikke delt med dig.',
|
||||
'notes.sharedBy': 'Delt af {name}',
|
||||
'notes.sharePeople': 'Del med personer',
|
||||
'notes.sharePick': 'Vælg en person …',
|
||||
@@ -76,7 +76,8 @@ export const da = {
|
||||
'notes.copied': 'Kopieret',
|
||||
'notes.copyFailed': 'Kunne ikke kopiere',
|
||||
'notes.strike': 'Gennemstreget',
|
||||
'notes.showSource': 'Vis som kode',
|
||||
'notes.code': 'Kode',
|
||||
'notes.showSource': 'Vis kilden (Markdown)',
|
||||
'notes.source': 'Notens kildetekst',
|
||||
'notes.styleTitle': 'Titel',
|
||||
'notes.styleHeading': 'Overskrift',
|
||||
@@ -451,6 +452,8 @@ export const da = {
|
||||
'view.filterEmpty': 'Ingenting matcher lige nu.',
|
||||
'view.filterFailed': 'Filteret kunne ikke køres.',
|
||||
'view.upcoming': 'Kommende',
|
||||
'view.horizon': 'Periode',
|
||||
'view.days': '{n} dage',
|
||||
'view.mode': 'Visning',
|
||||
'view.list': 'Liste',
|
||||
'view.week': 'Uge',
|
||||
|
||||
@@ -19,7 +19,7 @@ export const en = {
|
||||
// Sharing with individual people, alongside the project.
|
||||
'notes.groupSharedWithMe': 'Shared with me',
|
||||
'notes.linkOpen': 'Open',
|
||||
'notes.linkMissing': 'That note could not be found.',
|
||||
'notes.linkMissing': 'That note does not exist, or it is not shared with you.',
|
||||
'notes.sharedBy': 'Shared by {name}',
|
||||
'notes.sharePeople': 'Share with people',
|
||||
'notes.sharePick': 'Choose someone …',
|
||||
@@ -72,7 +72,8 @@ export const en = {
|
||||
'notes.copied': 'Copied',
|
||||
'notes.copyFailed': 'Could not copy',
|
||||
'notes.strike': 'Strikethrough',
|
||||
'notes.showSource': 'Show as code',
|
||||
'notes.code': 'Code',
|
||||
'notes.showSource': 'Show the source (Markdown)',
|
||||
'notes.source': 'Note source',
|
||||
'notes.styleTitle': 'Title',
|
||||
'notes.styleHeading': 'Heading',
|
||||
@@ -446,6 +447,8 @@ export const en = {
|
||||
'view.filterEmpty': 'Nothing matches right now.',
|
||||
'view.filterFailed': 'The filter could not be run.',
|
||||
'view.upcoming': 'Upcoming',
|
||||
'view.horizon': 'Range',
|
||||
'view.days': '{n} days',
|
||||
'view.mode': 'View',
|
||||
'view.list': 'List',
|
||||
'view.week': 'Week',
|
||||
|
||||
@@ -865,8 +865,8 @@ class SidebarLayout {
|
||||
export const sidebar = new SidebarLayout();
|
||||
|
||||
/**
|
||||
* Whether Kommende shows the next seven days as a list, one week as a grid, or the
|
||||
* whole month.
|
||||
* Whether Kommende shows a run of days as a list, one week as a grid, or the whole
|
||||
* month.
|
||||
*
|
||||
* In localStorage rather than on the account, and for the same reason as the
|
||||
* sidebar's width: a grid of seven columns needs room, and the answer on a phone is
|
||||
@@ -878,24 +878,48 @@ export const sidebar = new SidebarLayout();
|
||||
*/
|
||||
const UPCOMING_MODES = ['list', 'week', 'calendar'];
|
||||
|
||||
/**
|
||||
* How far the list looks ahead, in days.
|
||||
*
|
||||
* Seven days is a week's horizon and answers "what is coming"; thirty answers
|
||||
* "when does this month get busy", which is the question a month grid answers by
|
||||
* shape but not in order. The list is the only mode that takes a horizon — the
|
||||
* grids are anchored to a week and a month, and a fortnight is neither.
|
||||
*
|
||||
* The server caps the window at 31 days, so nothing here may exceed that.
|
||||
*/
|
||||
export const UPCOMING_DAYS = [7, 14, 30];
|
||||
|
||||
class UpcomingView {
|
||||
mode = $state(read());
|
||||
mode = $state(readChoice('verdande:upcoming', UPCOMING_MODES, 'list'));
|
||||
days = $state(readChoice('verdande:upcoming-days', UPCOMING_DAYS, 7, Number));
|
||||
|
||||
set(next) {
|
||||
if (!UPCOMING_MODES.includes(next)) return;
|
||||
this.mode = next;
|
||||
try {
|
||||
localStorage.setItem('verdande:upcoming', next);
|
||||
} catch {
|
||||
// Private browsing; the choice simply will not persist.
|
||||
}
|
||||
writeChoice('verdande:upcoming', next);
|
||||
}
|
||||
|
||||
setDays(next) {
|
||||
const n = Number(next);
|
||||
if (!UPCOMING_DAYS.includes(n)) return;
|
||||
this.days = n;
|
||||
writeChoice('verdande:upcoming-days', n);
|
||||
}
|
||||
}
|
||||
|
||||
function read() {
|
||||
if (typeof localStorage === 'undefined') return 'list';
|
||||
const stored = localStorage.getItem('verdande:upcoming');
|
||||
return UPCOMING_MODES.includes(stored) ? stored : 'list';
|
||||
function readChoice(key, allowed, fallback, parse = String) {
|
||||
if (typeof localStorage === 'undefined') return fallback;
|
||||
const stored = parse(localStorage.getItem(key));
|
||||
return allowed.includes(stored) ? stored : fallback;
|
||||
}
|
||||
|
||||
function writeChoice(key, value) {
|
||||
try {
|
||||
localStorage.setItem(key, String(value));
|
||||
} catch {
|
||||
// Private browsing; the choice simply will not persist.
|
||||
}
|
||||
}
|
||||
|
||||
export const upcomingView = new UpcomingView();
|
||||
|
||||
@@ -665,17 +665,38 @@
|
||||
* to their own pages. Until now these were text and nothing else: the note said
|
||||
* it pointed somewhere and there was no way to go.
|
||||
*/
|
||||
/**
|
||||
* Følger et link — og siger det, når det ikke kan følges.
|
||||
*
|
||||
* Et `[[link]]` peger på en note ved dens *titel*, så det skal slås op. Først i
|
||||
* listen, der allerede er hentet, og ellers med en søgning, fordi listen kan
|
||||
* være snævret ind af et søgeord.
|
||||
*
|
||||
* Kun en titel, der passer helt. Der stod `?? found[0]` her, så et link, der
|
||||
* ikke kunne findes, åbnede den første note, søgningen tilfældigvis gav — og i
|
||||
* en *delt* note er det næsten altid den note, man allerede står i: den nævner
|
||||
* jo titlen, den linker til. Så linket så ud til ikke at gøre noget som helst,
|
||||
* hvilket er præcis sådan det blev meldt. Et link, der rammer forbi, skal sige
|
||||
* det, ikke ramme et andet sted.
|
||||
*
|
||||
* Beskeden nævner delingen, fordi det er den almindelige årsag: en note, der
|
||||
* deles, deler ikke de noter, den peger på, og modtageren kan derfor se linket
|
||||
* uden at kunne åbne det. Herfra kan de to tilfælde ikke skelnes — svaret er
|
||||
* det samme, hvad enten noten ikke findes eller bare ikke er ens egen — og
|
||||
* sætningen siger dem begge frem for at gætte på den ene.
|
||||
*/
|
||||
async function openLink(link) {
|
||||
if (link.kind === 'note') {
|
||||
const wanted = link.target_id.toLowerCase();
|
||||
const here = notes.find((n) => (n.title ?? '').toLowerCase() === wanted);
|
||||
const wanted = link.target_id.trim().toLowerCase();
|
||||
const titled = (n) => (n.title ?? '').trim().toLowerCase();
|
||||
const here = notes.find((n) => titled(n) === wanted);
|
||||
if (here) {
|
||||
open(here);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const found = (await api.notes({ q: link.target_id })).notes ?? [];
|
||||
const hit = found.find((n) => (n.title ?? '').toLowerCase() === wanted) ?? found[0];
|
||||
const hit = found.find((n) => titled(n) === wanted);
|
||||
if (hit) open(hit);
|
||||
else app.toast(t('notes.linkMissing'));
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script>
|
||||
/**
|
||||
* Kommende: the next seven days as a list, one week as a grid, or the month.
|
||||
* Kommende: a run of days as a list, one week as a grid, or the month.
|
||||
*
|
||||
* The views ask for different things and none is a subset of the others — seven
|
||||
* days from today, against a chosen week, against six whole weeks around a
|
||||
@@ -15,7 +15,7 @@
|
||||
* A week that straddles the 31st and the 1st has both days in the same row.
|
||||
*/
|
||||
import { api } from '$lib/api.js';
|
||||
import { app, upcomingView } from '$lib/stores.svelte.js';
|
||||
import { app, upcomingView, UPCOMING_DAYS } from '$lib/stores.svelte.js';
|
||||
import { TASK, startDrag, carries, dragged, accept } from '$lib/dnd.js';
|
||||
import TaskRow from '$lib/components/TaskRow.svelte';
|
||||
import QuickAdd from '$lib/components/QuickAdd.svelte';
|
||||
@@ -24,9 +24,12 @@
|
||||
|
||||
let days = $state([]);
|
||||
|
||||
// Reads `upcomingView.days` inside the effect on purpose: that is what makes
|
||||
// choosing a longer horizon reload, rather than extending the strip with days
|
||||
// nobody asked the server about.
|
||||
$effect(() => {
|
||||
if (upcomingView.mode !== 'list') return;
|
||||
api.upcoming().then((data) => {
|
||||
api.upcoming(upcomingView.days).then((data) => {
|
||||
days = data.days;
|
||||
app.tasks = data.days.flatMap((d) => d.tasks);
|
||||
});
|
||||
@@ -53,6 +56,21 @@
|
||||
return day.toLocaleDateString(tag(), { weekday: 'long', day: 'numeric', month: 'short' });
|
||||
}
|
||||
|
||||
// A month of headings repeats every weekday four times, and "mandag 6. okt"
|
||||
// alone does not say which mandag. The week number is the label that does, and
|
||||
// it only earns its place once the strip is longer than a week.
|
||||
function weekOf(date) {
|
||||
if (upcomingView.days <= 7) return null;
|
||||
const day = new Date(date + 'T00:00:00');
|
||||
// ISO weeks: Thursday decides which year and which week a row belongs to.
|
||||
const thursday = new Date(day);
|
||||
thursday.setDate(day.getDate() - ((day.getDay() + 6) % 7) + 3);
|
||||
const firstThursday = new Date(thursday.getFullYear(), 0, 4);
|
||||
firstThursday.setDate(firstThursday.getDate() - ((firstThursday.getDay() + 6) % 7) + 3);
|
||||
const n = 1 + Math.round((thursday - firstThursday) / (7 * 86400000));
|
||||
return t('view.weekNumber', { n });
|
||||
}
|
||||
|
||||
// Reading tasks back out of the store keeps a completed row disappearing
|
||||
// immediately rather than on the next load. It is also what makes a task
|
||||
// dragged from one day to another land in its new section straight away: the
|
||||
@@ -82,6 +100,19 @@
|
||||
<div class="view" class:wide={upcomingView.mode !== 'list'}>
|
||||
<header>
|
||||
<h1>{t('view.upcoming')}</h1>
|
||||
{#if upcomingView.mode === 'list'}
|
||||
<!-- Only the list takes a horizon. A week grid is a week and a month grid
|
||||
is a month; there is no fortnight for them to show. -->
|
||||
<div class="views" role="group" aria-label={t('view.horizon')}>
|
||||
{#each UPCOMING_DAYS as n}
|
||||
<button
|
||||
class:active={upcomingView.days === n}
|
||||
onclick={() => upcomingView.setDays(n)}
|
||||
aria-pressed={upcomingView.days === n}>{t('view.days', { n })}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="views" role="group" aria-label={t('view.mode')}>
|
||||
{#each [['list', t('view.list')], ['week', t('view.week')], ['calendar', t('view.month')]] as [value, label]}
|
||||
<button
|
||||
@@ -102,6 +133,7 @@
|
||||
{:else}
|
||||
{#each days as day (day.date)}
|
||||
{@const tasks = live(day.date)}
|
||||
{@const week = weekOf(day.date)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<section
|
||||
class:over={over === day.date}
|
||||
@@ -109,7 +141,10 @@
|
||||
ondragleave={() => (over = null)}
|
||||
ondrop={(e) => onDrop(e, day.date)}
|
||||
>
|
||||
<h2>{heading(day.date)}</h2>
|
||||
<h2>
|
||||
{heading(day.date)}
|
||||
{#if week}<span class="week">{week}</span>{/if}
|
||||
</h2>
|
||||
{#if tasks.length}
|
||||
{#each tasks as task (task.id)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
@@ -210,6 +245,10 @@
|
||||
}
|
||||
|
||||
h2 {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--s2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 560;
|
||||
text-transform: uppercase;
|
||||
@@ -220,6 +259,14 @@
|
||||
margin-bottom: var(--s2);
|
||||
}
|
||||
|
||||
/* Lighter than the day it sits beside: it is there to be found when you are
|
||||
lost in the fourth week, not read on every row. */
|
||||
.week {
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* An em dash rather than "ingen opgaver": seven repetitions of a sentence is
|
||||
noise, and the reader only needs to know the day is clear. */
|
||||
.empty {
|
||||
|
||||
Reference in New Issue
Block a user