Compare commits

..
Author SHA1 Message Date
kristianwindandClaude Opus 5 08d703e3af fix(noter): følg note-links i en delt note, og lad </> være kode
To ting meldt af en, der havde en note delt med sig.

Note-linkene i den delte note gjorde ingenting. Opslaget faldt tilbage på det
første søgeresultat, når ingen titel passede — og i en delt note er det første
resultat næsten altid noten selv, fordi den nævner den titel, den peger på. Så
åbnede linket den note, man allerede stod i. Kun en titel, der passer helt,
tæller nu; og rammer den forbi, siger beskeden den rigtige grund: en note, der
deles, deler ikke de noter, den peger på, så modtageren kan se linket uden at
kunne åbne det.

Og `</>` sad på kildevisningen med teksten "Vis som kode" på. Tegnet betyder
inline kode i ethvert andet program, så et markeret ord plus et tryk gav hele
noten som Markdown. Nu gør `</>` det, det siger — ⌘E, en kontakt, og ordet under
markøren når der ikke er markeret noget — og kilden har fået Markdown-mærket og
sin egen tekst.

Kode indsættes som en node frem for med execCommand: Chromium renser den HTML
gennem indsætningsvejen og gav et <span> med skrifttypen skrevet ind i sig, som
htmlToMarkdown ikke kan skrive ned som kode.

To e2e-prøver, som begge fejler på koden før den her.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013r4DxpUZcSShxfCZZaeMGZ
2026-09-07 21:50:12 +02:00
Kristian Wind d4784fd174 Merge pull request #59 from kristianwind/upcoming-horizon
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Successful in 9m37s
CI / Docker image builds (push) Successful in 2m9s
Release / Build and publish image (push) Failing after 8s
CI / Go (push) Failing after 14m45s
Kommende: en horisont på 7, 14 eller 30 dage
2026-09-07 15:08:06 +02:00
kristianwindandClaude Opus 5 094ea5cdd8 feat(upcoming): look up to 30 days ahead, and choose how far
Kommendes liste has always been the next seven days and nothing else. Seven days
is the right walk to take in the morning, and it is still what the view opens on
— but "when does this month get busy" is a different question, and the month grid
answers it with shape rather than in order. So the list now takes a horizon: 7,
14 or 30 days, chosen with a switcher beside the view buttons.

Only the list gets one. A week grid is a week and a month grid is a month; there
is no fortnight for them to show, so the switcher is hidden in those modes.

The choice lives in localStorage next to the mode, under its own key. It is the
same kind of choice as list-or-grid — how you want to look, not a fact about the
work — and it should survive a reload without following you to a phone where
thirty day headings is a very long scroll.

Once the strip is longer than a week the day headings carry the week number.
Thirty days repeat every weekday four times, and "mandag 6. okt" alone does not
say which mandag.

Server side the endpoint already took `?days=` and capped it at 31, so the window
needed nothing — but its row limit was a constant 1000 written for a week. A
month asked for with a week's ceiling would truncate the last days silently, and
a day cut off the end of the answer looks exactly like a day with nothing due, so
the limit now follows the window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SA1TDmgQZw3AufJqkbwezx
2026-09-07 14:46:49 +02:00
Kristian Wind f60758b770 Merge pull request #58 from kristianwind/site-all-themes
docs(site): show all six themes, and put the demo line in English
2026-09-04 09:56:28 +02:00
12 changed files with 528 additions and 31 deletions
+1 -1
View File
@@ -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
View File
@@ -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
+43
View File
@@ -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)
+7 -2
View File
@@ -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)
+190
View File
@@ -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

+160 -4
View File
@@ -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">&lt;/&gt;</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')}>&lt;/&gt;</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. */
+5 -2
View File
@@ -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',
+5 -2
View File
@@ -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',
+36 -12
View File
@@ -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();
+24 -3
View File
@@ -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) {
+51 -4
View File
@@ -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 {