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
This commit is contained in:
co-authored by
Claude Opus 5
parent
f60758b770
commit
094ea5cdd8
+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('/');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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