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
This commit is contained in:
Kristian Wind
2026-09-07 15:08:06 +02:00
committed by GitHub
9 changed files with 202 additions and 20 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)
+54
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('/');
+2
View File
@@ -451,6 +451,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',
+2
View File
@@ -446,6 +446,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();
+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 {