feat(plan): dagens plan står på I dag, og ikke kun i en besked

Planen var en notifikation og ikke andet. Klokken klippede den af efter
én linje — "2 forfalder i dag, 1 er sprun…" — og klikket førte ingen
steder hen, fordi en plan hverken er en note, en opgave eller et projekt.
Det eneste sted, den landede hel, var push-beskeden på telefonen.

Nu står den som et kort øverst på I dag, dér hvor man kigger, når man
starter dagen. Beskeden er påmindelsen; kortet er stedet, den bliver.

Teksten er gemt for dagen frem for regnet forfra, hver gang nogen kigger.
En model pr. sidevisning er dyr for noget, der ikke har ændret sig — og
vigtigere: en plan, der siger noget andet, når man kigger på den, end den
sagde, da den kom, er værre end begge dele hver for sig. Kortet siger
også, hvornår den blev lavet: en plan fra klokken syv er en anden slags
oplysning klokken fire end klokken otte.

En plan fra i går sendes ikke med. Et kort, der viser gårsdagens plan
under overskriften "i dag", er værre end et tomt kort.

"Forny planen" laver en ny, når dagen har flyttet sig, og sender ikke en
besked om noget, man står og kigger på — den slags lærer folk at ignorere
klokken. Og for den, der har morgenbeskeden slået fra, tilbyder kortet at
lave planen på stedet, så funktionen ikke kun findes for dem, der vil
vækkes.

Klokken fører nu til I dag, og planen fylder de tre linjer, den er, i
stedet for at blive klippet af.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kristianwind
2026-09-09 10:22:27 +02:00
co-authored by Claude Opus 5
parent 3c14906db2
commit 6cf184caa6
11 changed files with 419 additions and 18 deletions
+3 -1
View File
@@ -496,7 +496,9 @@ export const api = {
// Dagens plan om morgenen.
planSettings: () => get('/ai/plan'),
setPlanSettings: (body) => put('/ai/plan', body),
planNow: () => post('/ai/plan/now'),
// `silent` er kortet på I dag, der beder om en plan, mens man kigger på den:
// en besked om noget, man står og ser på, lærer folk at ignorere klokken.
planNow: (options) => post('/ai/plan/now', options ?? {}),
version: () => get('/version'),
+13 -1
View File
@@ -56,6 +56,9 @@
// En ny version hører ingen steder hen i data — den fører hen, hvor knappen
// til at gøre noget ved den sidder.
if (n.kind === 'update.available') return goto('/indstillinger/notifikationer');
// Dagens plan står på I dag, hvor den bliver ved med at stå — beskeden er
// påmindelsen, kortet er stedet.
if (n.kind === 'daily.plan') return goto('/');
if (n.note_id) goto(`/noter?note=${n.note_id}`);
else if (n.task_id) app.openDetail(n.task_id);
else if (n.project_id) goto(`/projekt/${n.project_id}`);
@@ -111,7 +114,10 @@
<li>
<button class="row" class:unread={!n.read} onclick={() => follow(n)}>
<span class="what">{headline(n)}</span>
{#if n.body}<span class="about">{n.body}</span>{/if}
<!-- Dagens plan er tre linjer og ikke en overskrift. Klippet af
efter én linje sagde den "2 forfalder i dag, 1 er sprun…",
hvilket er den halve oplysning og hele støjen. -->
{#if n.body}<span class="about" class:full={n.kind === 'daily.plan'}>{n.body}</span>{/if}
<span class="ago">{when(n.created_at)}</span>
</button>
</li>
@@ -260,6 +266,12 @@
white-space: nowrap;
}
.about.full {
white-space: pre-line;
text-overflow: clip;
line-height: 1.5;
}
.ago {
font-size: var(--text-xs);
color: var(--ink-faint);
+4
View File
@@ -96,6 +96,10 @@ export const da = {
'ai.planHint':
'En besked om morgenen om det, der forfalder i dag, og det, der er sprunget over. Tallene tælles her; er der en model sat op, skriver den sætningen.',
'ai.planHour': 'Klokken',
'ai.planMade': 'lavet {time}',
'ai.planAgain': 'Forny planen',
'ai.planMake': 'Lav dagens plan',
'ai.planDismiss': 'Skjul planen i dag',
'ai.planNow': 'Send den nu',
'ai.planSent': 'Sendt — kig i klokken.',
'ai.planNothing': 'Der er ikke noget at planlægge i dag.',
+4
View File
@@ -93,6 +93,10 @@ export const en = {
'ai.planHint':
'A notification in the morning about what is due today and what has been missed. The numbers are counted here; if a model is set up, it writes the sentence.',
'ai.planHour': 'At',
'ai.planMade': 'made at {time}',
'ai.planAgain': 'Make it again',
'ai.planMake': "Make today's plan",
'ai.planDismiss': 'Hide the plan for today',
'ai.planNow': 'Send it now',
'ai.planSent': 'Sent — look in the bell.',
'ai.planNothing': 'There is nothing to plan for today.',
+165
View File
@@ -5,6 +5,7 @@
import TaskRow from '$lib/components/TaskRow.svelte';
import QuickAdd from '$lib/components/QuickAdd.svelte';
import { t, tag } from '$lib/i18n.svelte.js';
import { humanMessage } from '$lib/api.js';
let loaded = $state(false);
@@ -17,6 +18,72 @@
).padStart(2, '0')}`;
})();
/**
* Dagens plan, dér hvor man kigger, når man starter dagen.
*
* Hentet og ikke regnet forfra: det er den samme tekst, beskeden i morges bar.
* En plan, der siger noget andet, når man kigger på den, end den sagde, da den
* kom, er værre end begge dele hver for sig — og en model pr. sidevisning er
* dyr for noget, der ikke har ændret sig.
*
* Lukket for i dag frem for slettet. Kortet er en påmindelse, ikke en opgave,
* og den, der har læst den, skal kunne få den af vejen uden at miste den —
* derfor står lukningen i localStorage med dagens dato og ikke på kontoen.
*/
let plan = $state(null);
let planBusy = $state(false);
let planError = $state('');
const DISMISS_KEY = 'verdande:plan-dismissed';
let dismissed = $state(
typeof localStorage !== 'undefined' && localStorage.getItem(DISMISS_KEY) === isoToday
);
function dismissPlan() {
dismissed = true;
try {
localStorage.setItem(DISMISS_KEY, isoToday);
} catch {
// Privat vindue; kortet er væk til næste indlæsning.
}
}
async function makePlan() {
if (planBusy) return;
planBusy = true;
planError = '';
try {
// Stille: man står og kigger på den. En besked om noget, man har foran
// sig, er den slags, der lærer folk at ignorere klokken.
const made = await api.planNow({ silent: true });
plan = { ...plan, plan: made.plan, plan_at: made.plan_at };
dismissed = false;
} catch (e) {
planError = humanMessage(e);
} finally {
planBusy = false;
}
}
$effect(() => {
api
.planSettings()
.then((p) => (plan = p))
.catch(() => {
// Et kort, der ikke kunne hentes, må ikke stå i vejen for listen.
});
});
const planMade = $derived(
plan?.plan_at
? new Date(plan.plan_at * 1000).toLocaleTimeString(tag(), {
hour: '2-digit',
minute: '2-digit'
})
: ''
);
async function load() {
const data = await api.today();
app.tasks = [...data.overdue, ...data.today];
@@ -54,6 +121,33 @@
<QuickAdd />
{#if plan?.plan && !dismissed}
<!-- Teksten som den blev sendt, og klokkeslættet den blev lavet: en plan fra
klokken syv er en anden slags oplysning klokken fire end klokken otte. -->
<section class="plan">
<header>
<h2>{t('ai.plan')}</h2>
{#if planMade}<span class="made">{t('ai.planMade', { time: planMade })}</span>{/if}
<button class="close" onclick={dismissPlan} aria-label={t('ai.planDismiss')}>×</button>
</header>
<p>{plan.plan}</p>
<button class="again" onclick={makePlan} disabled={planBusy}>
{planBusy ? t('ai.thinking') : t('ai.planAgain')}
</button>
</section>
{:else if plan && !plan.plan && (liveToday.length || liveOverdue.length)}
<!-- Ingen plan i dag: enten er beskeden slået fra, eller også er timen ikke
kommet endnu. Knappen frem for ingenting, så den, der har slået
morgenbeskeden fra, stadig kan bede om planen. Kun når der er noget at
lægge en plan om — en tom dag har ikke brug for en knap. -->
<p class="planask">
<button class="again" onclick={makePlan} disabled={planBusy}>
{planBusy ? t('ai.thinking') : t('ai.planMake')}
</button>
{#if planError}<span class="planerror">{planError}</span>{/if}
</p>
{/if}
{#if liveOverdue.length}
<section>
<h2 class="overdue">{t('view.overdue')}</h2>
@@ -93,6 +187,77 @@
margin-bottom: var(--s5);
}
/* Kortet ligger mellem feltet og listen: under det, man skriver i, og over det,
man skal se på. Dæmpet frem for fremhævet — det er en påmindelse om, hvad
dagen indeholder, ikke en opgave, der skal laves. */
.plan {
margin: 0 0 var(--s5);
padding: var(--s3);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface-raised, var(--surface));
}
.plan header {
display: flex;
align-items: baseline;
gap: var(--s2);
margin: 0 0 var(--s2);
}
.plan h2 {
font-size: var(--text-sm);
margin: 0;
}
.plan .made {
color: var(--ink-faint);
font-size: var(--text-xs);
}
.plan .close {
margin-left: auto;
border: none;
background: none;
color: var(--ink-faint);
cursor: pointer;
line-height: 1;
font-size: var(--text-md);
}
/* Linjeskift bevaret: planen er to-tre linjer, og de er skrevet som linjer. */
.plan p {
margin: 0 0 var(--s2);
font-size: var(--text-sm);
line-height: 1.6;
white-space: pre-line;
}
.again {
border: none;
background: none;
padding: 0;
font: inherit;
font-size: var(--text-xs);
color: var(--accent);
cursor: pointer;
}
.again:disabled {
color: var(--ink-faint);
cursor: default;
}
.planask {
margin: 0 0 var(--s5);
}
.planerror {
margin-left: var(--s2);
font-size: var(--text-xs);
color: var(--ink-muted);
}
h1 {
font-size: var(--text-2xl);
}