diff --git a/docs/api.md b/docs/api.md index 01eb8f2..7898114 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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. | diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 2e4d695..ca7cd6b 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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 diff --git a/internal/httpapi/tasks_test.go b/internal/httpapi/tasks_test.go index c532186..830e403 100644 --- a/internal/httpapi/tasks_test.go +++ b/internal/httpapi/tasks_test.go @@ -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) diff --git a/internal/httpapi/views_handlers.go b/internal/httpapi/views_handlers.go index 2d623a9..20c339c 100644 --- a/internal/httpapi/views_handlers.go +++ b/internal/httpapi/views_handlers.go @@ -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) diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index d70f44a..a67da63 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -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('/'); diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js index ffd45ec..26c1ebf 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -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', diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js index 5a18bb3..0b317d3 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -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', diff --git a/web/src/lib/stores.svelte.js b/web/src/lib/stores.svelte.js index 7e7a9ea..30b5731 100644 --- a/web/src/lib/stores.svelte.js +++ b/web/src/lib/stores.svelte.js @@ -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(); diff --git a/web/src/routes/upcoming/+page.svelte b/web/src/routes/upcoming/+page.svelte index 635f807..3488ce4 100644 --- a/web/src/routes/upcoming/+page.svelte +++ b/web/src/routes/upcoming/+page.svelte @@ -1,6 +1,6 @@