feat(tasks): tid på en opgave, og et træk i kalenderen sætter den
Release / Build and publish image (push) Failing after 9m58s

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:
Kristian Wind
2026-08-20 20:29:03 +02:00
co-authored by Claude Opus 5
parent e29fd04abd
commit ae673086bc
6 changed files with 244 additions and 20 deletions
+49 -2
View File
@@ -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>
+50 -7
View File
@@ -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;