feat(tasks): tid på en opgave, og et træk i kalenderen sætter den
Bedt om: "jeg vil have tid på opgaver, og jeg vil kunne justere dem i kalenderen."
Serveren kunne det hele i forvejen — `due_date` og `due_time` læses sammen og
bliver til et `due_datetime` i kontoens tidszone. Det var fladen, der ikke havde
nogen vej til at sige det.
**Et felt i opgaveruden.** Tomt, indtil nogen sætter det: en opgave uden
klokkeslæt er det almindelige, og et felt, der åbner på 00:00, påstår, at noget
skal ske ved midnat.
**Og en fejl, der lå og ventede.** Serveren læser en dato uden en tid som "ryd
tiden". Så et gem af datoen alene tog klokkeslættet med sig — man rettede dagen og
mistede timen — og det samme gjorde hvert eneste træk i kalenderen. Ingen af
delene sagde noget.
`reschedule` siger nu tiden højt, tre måder, fordi de to første ligner hinanden i
JavaScript og derfor skal skilles ad ét sted frem for at blive forvekslet fire:
undefined behold den, opgaven har — månedens ruder, hvor der ingen tid er at læse
'' ryd den — heldagsbåndet
'HH:MM' sæt den — et sted på døgnet
**Hvor på søjlen man slipper, er klokkeslættet.** Målt mod søjlens egen kasse frem
for mod siden, så det bliver rigtigt uanset hvor gitteret sidder og hvor langt der
er rullet. Rundet til kvarter: minuttet under markøren er ikke en oplysning, nogen
har — pegefingeren er bredere end et minut — og 14:07 er et tal, der ser ud som om
det blev valgt.
Prøven går hele vejen: sæt tiden, genindlæs og se at serveren tog den, ret datoen
og se at timen bliver, find opgaven på sit klokkeslæt i ugegitteret, og træk den op
i båndet igen. To ting, den lærte undervejs, står skrevet ved den: et træk over
fire hundrede pixels skal gå i skridt, ellers har browseren for få mellemstationer
til at holde gesten i live — og klokken skal være tidlig, for klokken fjorten
ligger under skærmkanten, og at måle noget dernede ruller siden væk under målet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e29fd04abd
commit
36f22d44eb
+101
-8
@@ -983,11 +983,11 @@ test('en opgave kan trækkes hen over et månedsskifte i uge-visningen', async (
|
||||
// slipper der, hvor man kan se, man slipper.
|
||||
await chip.dragTo(page.locator(`[data-date="${to}"]`), { targetPosition: { x: 30, y: 20 } });
|
||||
|
||||
// Sluppet på timesøjlen, landet i heldagsbåndet — og det er det rigtige sted.
|
||||
// Et træk hen på en dag flytter datoen og sætter ikke et klokkeslæt, så opgaven
|
||||
// har ingen tid at stå ved. `data-allday` er den anden halvdel af dagen: siden
|
||||
// ugen blev et døgn, er en dag to kasser, og en prøve skal sige hvilken.
|
||||
await expect(page.locator(`[data-allday="${to}"]`).getByText('skifte dæk')).toBeVisible();
|
||||
// Landet i timesøjlen med et klokkeslæt: hvor på døgnet man slap, *er* tiden.
|
||||
// Sluppet tyve pixels nede i en søjle, der begynder klokken otte, bliver det
|
||||
// tidligt på dagen — hvad det præcist runder til, er ikke det, der prøves her.
|
||||
await expect(page.locator(`[data-date="${to}"] .tevent`).getByText('skifte dæk')).toBeVisible();
|
||||
await expect(page.locator(`[data-date="${from}"]`).getByText('skifte dæk')).toBeHidden();
|
||||
await expect(page.locator(`[data-allday="${from}"]`).getByText('skifte dæk')).toBeHidden();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
@@ -1178,9 +1178,8 @@ test('kalenderen viser Googles begivenheder over egne opgaver, og kun opgaven ka
|
||||
// få prøven til at bestå: det er også sådan et menneske gør det, fordi man
|
||||
// slipper der, hvor man kan se, man slipper.
|
||||
await task.dragTo(page.locator(`[data-date="${next}"]`), { targetPosition: { x: 30, y: 20 } });
|
||||
// Landet i næste dags bånd: et træk hen på en dag flytter datoen og sætter ikke
|
||||
// et klokkeslæt, så opgaven har ingen tid at stå ved.
|
||||
await expect(page.locator(`[data-allday="${next}"]`).getByText('vande blomster')).toBeVisible();
|
||||
// Landet i næste dags timesøjle, med det klokkeslæt der blev sluppet på.
|
||||
await expect(page.locator(`[data-date="${next}"] .tevent`).getByText('vande blomster')).toBeVisible();
|
||||
await expect(band.getByText('vande blomster')).toBeHidden();
|
||||
// The events stayed where they were; a rescheduled task moves nothing else.
|
||||
await expect(cell.locator('.tevent')).toHaveCount(1);
|
||||
@@ -3210,3 +3209,97 @@ test('ugen viser et døgn, og dagene er lige brede', async ({ page }) => {
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Tid på en opgave, sat i ruden og justeret i kalenderen.
|
||||
*
|
||||
* Tre påstande, og den midterste er den, der var gået galt uden at nogen sagde
|
||||
* det: serveren læser `due_date` og `due_time` sammen, så en dato sendt uden en
|
||||
* tid *rydder* tiden. Et gem af datoen alene tog altså klokkeslættet med sig, og
|
||||
* det samme gjorde et træk i kalenderen.
|
||||
*/
|
||||
test('en opgave kan få et klokkeslæt, og beholder det når den flyttes', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
const box = page.getByLabel('Ny opgave');
|
||||
// I dag, så den står på den visning, den skrives i — og i den uge, gitteret
|
||||
// åbner på længere nede.
|
||||
await box.fill('ringe til tandlægen i dag');
|
||||
await box.press('Enter');
|
||||
await expect(page.getByText('ringe til tandlægen')).toBeVisible();
|
||||
|
||||
// Klokkeslættet sættes i ruden. Feltet er tomt til at begynde med: en opgave
|
||||
// uden tid er det almindelige, og 00:00 ville påstå noget om midnat.
|
||||
await page.getByText('ringe til tandlægen').click();
|
||||
const time = page.getByLabel('Klokkeslæt');
|
||||
await expect(time).toHaveValue('');
|
||||
// Tidligt på dagen med vilje: gitteret begynder klokken otte, så opgaven står
|
||||
// lige under båndet og de to kasser er i billedet på én gang. Klokken fjorten
|
||||
// ligger under skærmkanten, og et træk, der først skal rulle, er et træk,
|
||||
// browseren taber.
|
||||
await time.fill('08:30');
|
||||
await time.blur();
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
// Gemt hos serveren, ikke kun i feltet: genindlæst og åbnet igen.
|
||||
await page.reload();
|
||||
await page.getByText('ringe til tandlægen').click();
|
||||
await expect(page.getByLabel('Klokkeslæt'), 'klokkeslættet blev ikke gemt').toHaveValue('08:30');
|
||||
|
||||
// Og datoen kan rettes bagefter uden at tage timen med sig.
|
||||
const date = page.locator('#due');
|
||||
const day = await date.inputValue();
|
||||
await date.fill(day);
|
||||
await date.blur();
|
||||
await page.waitForTimeout(400);
|
||||
await page.reload();
|
||||
await page.getByText('ringe til tandlægen').click();
|
||||
await expect(
|
||||
page.getByLabel('Klokkeslæt'),
|
||||
'et gem af datoen ryddede klokkeslættet'
|
||||
).toHaveValue('08:30');
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// I ugegitteret står den på sit klokkeslæt frem for i heldagsbåndet.
|
||||
await page.goto('/upcoming');
|
||||
await page.getByRole('button', { name: 'Uge', exact: true }).click();
|
||||
const placed = page.locator('.tevent.task').filter({ hasText: 'ringe til tandlægen' });
|
||||
await expect(placed, 'opgaven står ikke på døgnet').toBeVisible();
|
||||
await expect(placed).toContainText('08:30');
|
||||
|
||||
// Trukket op i båndet over sin egen dag: så har den en dag og ingen tid. Den
|
||||
// tomme streng siger "ryd", hvor ingenting siger "behold" — og de to må ikke
|
||||
// forveksles.
|
||||
//
|
||||
// Et bånd, der er tomt. Prøven handler om at krydse mellem de to kasser, og en
|
||||
// dag, hvis bånd allerede er fyldt af andre prøvers opgaver, gør slippunktet
|
||||
// til en anden opgave frem for til båndet — hvilket er en anden gest end den,
|
||||
// der prøves her.
|
||||
const band = page
|
||||
.locator('.weekgrid > .allday')
|
||||
.filter({ hasNot: page.locator('.chip, .event') })
|
||||
.first();
|
||||
const today = await band.getAttribute('data-allday');
|
||||
// Trukket i skridt frem for i ét spring. `dragTo` flytter musen i to træk, og
|
||||
// over fire hundrede pixels i et travlt gitter giver det browseren for få
|
||||
// mellemstationer til at holde gesten i live. Et menneske bevæger hånden hele
|
||||
// vejen, og det er også det, der skal prøves.
|
||||
const fromBox = await placed.boundingBox();
|
||||
const toBox = await band.boundingBox();
|
||||
await page.mouse.move(fromBox.x + fromBox.width / 2, fromBox.y + 5);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(toBox.x + toBox.width / 2, toBox.y + toBox.height / 2, { steps: 25 });
|
||||
await page.mouse.up();
|
||||
await expect(
|
||||
page.locator(`[data-allday="${today}"] .chip`).filter({ hasText: 'ringe til tandlægen' }),
|
||||
'opgaven kom ikke op i båndet'
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator(`[data-date="${today}"] .tevent`).filter({ hasText: 'ringe til tandlægen' }),
|
||||
'opgaven står stadig på et klokkeslæt'
|
||||
).toBeHidden();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -272,6 +272,15 @@
|
||||
over = iso(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sluppet på en dag, uden at sige noget om klokkeslættet — så beholder opgaven
|
||||
* det, den har. Måneden er den her: en rude er en dag, ikke et døgn, og der er
|
||||
* ingen tid at læse ud af hvor man slap.
|
||||
*
|
||||
* Det var ikke sådan før. Trækket sendte kun datoen, og serveren læser en dato
|
||||
* uden en tid som "ryd tiden" — så en opgave klokken 14 mistede sit
|
||||
* klokkeslæt ved at blive flyttet til dagen efter, uden at nogen bad om det.
|
||||
*/
|
||||
async function onDrop(event, date) {
|
||||
event.preventDefault();
|
||||
const id = dragged(event, TASK);
|
||||
@@ -280,6 +289,44 @@
|
||||
await app.reschedule(id, iso(date));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sluppet et sted på døgnet: dér, man slap, er klokkeslættet.
|
||||
*
|
||||
* Målt mod søjlens egen kasse frem for mod siden, så det bliver rigtigt, uanset
|
||||
* hvor gitteret sidder, og uanset hvor langt der er rullet.
|
||||
*
|
||||
* Rundet til kvarter. Minuttet under markøren er ikke en oplysning, nogen har —
|
||||
* pegefingeren er bredere end et minut — og 14:07 er et tal, der ser ud som om
|
||||
* det blev valgt, hvilket er værre end at være omtrentligt.
|
||||
*/
|
||||
async function onDropAt(event, date) {
|
||||
event.preventDefault();
|
||||
const id = dragged(event, TASK);
|
||||
over = null;
|
||||
if (!id) return;
|
||||
|
||||
const box = event.currentTarget.getBoundingClientRect();
|
||||
const share = Math.min(Math.max((event.clientY - box.top) / box.height, 0), 1);
|
||||
const minutes = hours.from * 60 + share * hours.minutes;
|
||||
const snapped = Math.min(Math.round(minutes / 15) * 15, 24 * 60 - 15);
|
||||
await app.reschedule(id, iso(date), clock(snapped));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sluppet i båndet foroven: opgaven har en dag og ingen tid.
|
||||
*
|
||||
* Den tomme streng er ikke det samme som ingenting her — den siger "ryd", hvor
|
||||
* `undefined` siger "behold". Det er hele grunden til, at de to er skilt ad i
|
||||
* `reschedule`.
|
||||
*/
|
||||
async function onDropAllDay(event, date) {
|
||||
event.preventDefault();
|
||||
const id = dragged(event, TASK);
|
||||
over = null;
|
||||
if (!id) return;
|
||||
await app.reschedule(id, iso(date), '');
|
||||
}
|
||||
|
||||
// --- ugen som et døgn ---------------------------------------------------------
|
||||
//
|
||||
// En uge, hvor hver dag er en punktopstilling, svarer på "hvad skal der ske
|
||||
@@ -525,7 +572,7 @@
|
||||
class:over={over === iso(date)}
|
||||
ondragover={(e) => onDragOver(e, date)}
|
||||
ondragleave={() => (over = null)}
|
||||
ondrop={(e) => onDrop(e, date)}
|
||||
ondrop={(e) => onDropAllDay(e, date)}
|
||||
>
|
||||
{#each untimedOn(date) as item (item.kind + item.ref.id)}
|
||||
{#if item.kind === 'event'}
|
||||
@@ -556,7 +603,7 @@
|
||||
class:over={over === iso(date)}
|
||||
ondragover={(e) => onDragOver(e, date)}
|
||||
ondragleave={() => (over = null)}
|
||||
ondrop={(e) => onDrop(e, date)}
|
||||
ondrop={(e) => onDropAt(e, date)}
|
||||
>
|
||||
{#each Array(hours.count) as _, i}
|
||||
<div class="hourline" style="top:{(i / hours.count) * 100}%"></div>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* necessarily the last one sent.
|
||||
*/
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app, projectName } from '$lib/stores.svelte.js';
|
||||
import { app, projectName, clockOf } from '$lib/stores.svelte.js';
|
||||
import { focusOnMount } from '$lib/focus.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { REPEATS } from '$lib/when.js';
|
||||
@@ -22,6 +22,7 @@
|
||||
let description = $state('');
|
||||
let priority = $state(4);
|
||||
let dueDate = $state('');
|
||||
let dueTime = $state('');
|
||||
let labels = $state('');
|
||||
let projectId = $state('');
|
||||
let recurrence = $state('');
|
||||
@@ -55,6 +56,7 @@
|
||||
description = task.description ?? '';
|
||||
priority = task.priority;
|
||||
dueDate = task.due_date ?? '';
|
||||
dueTime = task.due_datetime ? clockOf(task.due_datetime) : '';
|
||||
labels = (task.labels ?? []).join(', ');
|
||||
projectId = task.project_id;
|
||||
recurrence = task.recurrence_rule ?? '';
|
||||
@@ -407,14 +409,34 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Dato og klokkeslæt gemmes sammen, altid.
|
||||
|
||||
Serveren læser de to felter som ét: kommer datoen uden en tid, *ryddes*
|
||||
tiden. Så et gem af datoen alene tog klokkeslættet med sig — man rettede
|
||||
dagen og mistede timen, uden at noget sagde det.
|
||||
|
||||
Tiden er tom, indtil nogen sætter den. En opgave uden klokkeslæt er det
|
||||
almindelige, og et felt, der åbner på 00:00, er et felt, der påstår, at
|
||||
noget skal ske ved midnat.
|
||||
-->
|
||||
<div class="field">
|
||||
<label for="due">{t('detail.due')}</label>
|
||||
<input
|
||||
id="due"
|
||||
type="date"
|
||||
bind:value={dueDate}
|
||||
onchange={() => save({ due_date: dueDate })}
|
||||
/>
|
||||
<div class="due">
|
||||
<input
|
||||
id="due"
|
||||
type="date"
|
||||
bind:value={dueDate}
|
||||
onchange={() => save({ due_date: dueDate, due_time: dueTime })}
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
aria-label={t('detail.dueTime')}
|
||||
bind:value={dueTime}
|
||||
disabled={!dueDate}
|
||||
onchange={() => save({ due_date: dueDate, due_time: dueTime })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -692,6 +714,27 @@
|
||||
padding-bottom: max(var(--s8), env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
/* Dato og tid på én linje: de er ét svar på ét spørgsmål, og tiden er den
|
||||
smalle halvdel af det. */
|
||||
.due {
|
||||
display: flex;
|
||||
gap: var(--s2);
|
||||
}
|
||||
|
||||
.due input[type='date'] {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.due input[type='time'] {
|
||||
flex: none;
|
||||
width: 7.5rem;
|
||||
}
|
||||
|
||||
.due input:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -551,6 +551,7 @@ export const da = {
|
||||
'detail.p1': 'P1 — haster',
|
||||
'detail.none': 'Ingen',
|
||||
'detail.due': 'Forfalder',
|
||||
'detail.dueTime': 'Klokkeslæt',
|
||||
'detail.labels': 'Etiketter',
|
||||
'detail.labelsPlaceholder': 'adskilt af komma',
|
||||
'detail.project': 'Projekt',
|
||||
|
||||
@@ -547,6 +547,7 @@ export const en = {
|
||||
'detail.p1': 'P1 — urgent',
|
||||
'detail.none': 'None',
|
||||
'detail.due': 'Due',
|
||||
'detail.dueTime': 'Time',
|
||||
'detail.labels': 'Labels',
|
||||
'detail.labelsPlaceholder': 'separated by commas',
|
||||
'detail.project': 'Project',
|
||||
|
||||
@@ -372,10 +372,35 @@ class AppState {
|
||||
}
|
||||
|
||||
/** Puts a task on a day — dropping it on one, in Kommende or in a month grid. */
|
||||
async reschedule(id, date) {
|
||||
/**
|
||||
* Flytter en opgave til en dag — og afgør, hvad der sker med klokkeslættet.
|
||||
*
|
||||
* Serveren læser `due_date` og `due_time` sammen: kommer datoen uden en tid,
|
||||
* *ryddes* tiden. Det er den rigtige regel for et felt, man skriver i, og den
|
||||
* forkerte for et træk i en kalender — en opgave, der stod klokken 14, mistede
|
||||
* sit klokkeslæt ved at blive trukket til dagen efter, uden at nogen bad om
|
||||
* det, og uden at noget sagde det.
|
||||
*
|
||||
* Så tiden siges nu højt, tre måder:
|
||||
*
|
||||
* time === undefined behold den, opgaven har
|
||||
* time === '' ryd den — det er heldagsbåndet
|
||||
* time === 'HH:MM' sæt den — det er et sted på døgnet
|
||||
*
|
||||
* `undefined` og `''` er med vilje to forskellige ting her. De ligner hinanden
|
||||
* i JavaScript, og det er præcis derfor de skal skilles ad ét sted frem for at
|
||||
* blive forvekslet fire steder.
|
||||
*/
|
||||
async reschedule(id, date, time) {
|
||||
const previous = this.get(id);
|
||||
if (!previous || previous.due_date === date) return;
|
||||
await this.update(id, { due_date: date });
|
||||
if (!previous) return;
|
||||
|
||||
const clock =
|
||||
time === undefined ? (previous.due_datetime ? clockOf(previous.due_datetime) : '') : time;
|
||||
if (previous.due_date === date && (previous.due_datetime ? clockOf(previous.due_datetime) : '') === clock) {
|
||||
return;
|
||||
}
|
||||
await this.update(id, { due_date: date, due_time: clock });
|
||||
}
|
||||
|
||||
async remove(id) {
|
||||
@@ -729,6 +754,20 @@ class AppState {
|
||||
* Only while it still carries a name the server gave it. Rename it and that is your
|
||||
* name for it, in every language — the same rule as any other project.
|
||||
*/
|
||||
/**
|
||||
* Klokkeslættet på en opgave, som "HH:MM" i den maskine, der ser på det.
|
||||
*
|
||||
* `Date` og ikke en strengudskæring: `due_datetime` kommer som UTC med et Z på,
|
||||
* og de fjorten på et stempel er ikke klokken fjorten for den, der læser det.
|
||||
* TaskRow viser tiden på nøjagtig samme måde, og de to steder skal vise det
|
||||
* samme tal.
|
||||
*/
|
||||
export function clockOf(stamp) {
|
||||
const at = new Date(stamp);
|
||||
if (Number.isNaN(at.getTime())) return '';
|
||||
return `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const SYSTEM_INBOX_NAMES = new Set(['indbakke', 'inbox']);
|
||||
|
||||
export function projectName(project) {
|
||||
|
||||
Reference in New Issue
Block a user