diff --git a/docs/ai.md b/docs/ai.md index 4b6584b..b9efa62 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -77,11 +77,21 @@ never edited — the task carries `[[the note's title]]` in its description inst **Today's plan, in the morning.** **Settings → Beskeder → Dagens plan**, off by default; pick the hour yourself. One notification about what is due today and what -has been missed, and what to start with. The counts are done in the database and -handed to the model as facts it may not recompute — a plan that says four tasks -where there are three is worse than no plan. With no model configured the counts -stand on their own, which is what you needed to know at seven in the morning anyway. -Nothing due and nothing overdue sends nothing. +has been missed, and what to start with — and the same text as a card at the top of +**I dag**, where you are looking anyway when you start the day. The notification is +the reminder; the card is where it stays. + +The card shows the plan *as it was sent*, with the time it was made, rather than a +new one each time you look: a plan that says something different when you look at it +than it said when it arrived is worse than either. **Forny planen** makes a new one +when the day has moved, and it does not send a notification about something you are +already looking at. With the morning notification off, the card offers to make one +on the spot. + +The counts are done in the database and handed to the model as facts it may not +recompute — a plan that says four tasks where there are three is worse than no plan. +With no model configured the counts stand on their own, which is what you needed to +know at seven in the morning anyway. Nothing due and nothing overdue sends nothing. **Mail that becomes a task you can act on.** Where a [mailbox](mailboxes.md) used to produce *"Anders Jensen: SV: SV: Vedr. levering uge diff --git a/docs/notifications.md b/docs/notifications.md index 450e0e1..c03b148 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -14,7 +14,7 @@ the app's icon. | Somebody named you in a note with `@` | Instead of "edited a note" — one act, one message | | Somebody gave you a task | Only on the change, and only to the person who got it | | Somebody commented | | -| [Today's plan](ai.md), if you turned it on | In the morning, at the hour you chose | +| [Today's plan](ai.md), if you turned it on | In the morning, at the hour you chose — it opens the card on **I dag** | | A new version is out | Administrators only, once per version | Not your own actions: the server does not tell anybody what they just did diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 52669b5..b3efd12 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -3688,7 +3688,21 @@ paths: /api/v1/ai/plan/now: post: tags: [integrations] - summary: Send today's plan now, to whoever asks. + summary: Make today's plan now, to whoever asks. + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + silent: + type: boolean + description: > + Make and store it without sending a notification — the card on + Today asking for a plan while somebody is looking at it. A + notification about something in front of you is the kind that + teaches people to ignore the bell. description: | The counts come from the database and the sentence from the model, in that order: a model that counted for itself would sometimes count wrong, and a @@ -4545,8 +4559,9 @@ components: DailyPlanSettings: type: object description: > - One person's morning plan. Off by default — a notification at seven in the - morning that nobody asked for is not a help. + One person's morning plan, and today's plan itself. One resource, because + it is one thing: the plan is the resource and the setting is when it gets + made. properties: enabled: { type: boolean } hour: @@ -4555,6 +4570,17 @@ components: last_sent: type: string description: The date it last went out, in that person's own timezone. + plan: + type: string + description: > + Today's plan as it was sent — kept rather than recomputed on every + look. A plan that says something different when you look at it than it + said when it arrived is worse than either. Absent when the stored plan + is from another day. + plan_date: { type: string } + plan_at: + type: integer + description: Unix seconds. Shown on the card, because a plan made at seven reads differently at four. AISuggestion: type: object diff --git a/internal/httpapi/ai_plan_handlers.go b/internal/httpapi/ai_plan_handlers.go index 4a64b86..33a0091 100644 --- a/internal/httpapi/ai_plan_handlers.go +++ b/internal/httpapi/ai_plan_handlers.go @@ -39,6 +39,19 @@ type planSettings struct { // tidszone. En dato og ikke et tidsstempel, fordi spørgsmålet er "har de fået // dagens plan i dag" — og det er et spørgsmål om dagen dér, hvor de er. LastSent string `json:"last_sent,omitempty"` + // Plan er teksten, som den blev sendt, og PlanDate den dag den gælder. + // + // Gemt frem for regnet forfra, hver gang nogen kigger. To grunde, og den anden + // er den vigtige: en model pr. sidevisning er dyr, og en plan, der siger noget + // andet end den besked, man fik i morges, er værre end begge dele hver for + // sig. Det er den samme tekst — ellers er det ikke *dagens plan*, men et nyt + // gæt hver gang man kigger. + // + // PlanAt er klokkeslættet, den blev lavet, så kortet kan sige det: en plan fra + // klokken syv er en anden slags oplysning klokken fire end klokken otte. + Plan string `json:"plan,omitempty"` + PlanDate string `json:"plan_date,omitempty"` + PlanAt int64 `json:"plan_at,omitempty"` } func (s *Server) planSettings(ctx context.Context, userID string) (planSettings, error) { @@ -56,21 +69,44 @@ func (s *Server) planSettings(ctx context.Context, userID string) (planSettings, if v, ok := stored["last_sent"].(string); ok { out.LastSent = v } + if v, ok := stored["plan"].(string); ok { + out.Plan = v + } + if v, ok := stored["plan_date"].(string); ok { + out.PlanDate = v + } + if v, ok := stored["plan_at"].(float64); ok { + out.PlanAt = int64(v) + } return out, nil } func (s *Server) savePlanSettings(ctx context.Context, userID string, p planSettings) error { return s.db.SetUserSettings(ctx, userID, planScope, map[string]any{ "enabled": p.Enabled, "hour": p.Hour, "last_sent": p.LastSent, + "plan": p.Plan, "plan_date": p.PlanDate, "plan_at": p.PlanAt, }) } +// handleGetPlanSettings er både indstillingen og dagens plan. +// +// Ét endepunkt, fordi det er én ting: planen *er* ressourcen, og indstillingen er, +// hvornår den bliver lavet. To ville betyde to kald fra siden, der viser den, og +// to steder at huske at rydde en plan fra i går. +// +// En plan fra i går sendes ikke med. Den er ikke forkert, den er bare ikke dagens +// — og et kort, der viser gårsdagens plan under overskriften "i dag", er værre end +// et tomt kort. func (s *Server) handleGetPlanSettings(w http.ResponseWriter, r *http.Request) { - p, err := s.planSettings(r.Context(), userFrom(r.Context()).ID) + user := userFrom(r.Context()) + p, err := s.planSettings(r.Context(), user.ID) if err != nil { s.internal(w, r, "plan settings", err) return } + if p.PlanDate != time.Now().In(userLocation(user.Timezone)).Format("2006-01-02") { + p.Plan, p.PlanDate, p.PlanAt = "", "", 0 + } writeJSON(w, http.StatusOK, p) } @@ -111,12 +147,29 @@ func (s *Server) handleSetPlanSettings(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, p) } -// handlePlanNow sender dagens plan med det samme, til den der beder om det. +type planNowRequest struct { + // 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å, er en besked, der lærer folk at + // ignorere klokken. Indstillingssidens knap sender derimod rigtigt — det er + // hele dens formål: at vise, at push virker på det her apparat. + Silent bool `json:"silent"` +} + +// handlePlanNow laver dagens plan med det samme, til den der beder om det. // // Findes, fordi en indstilling, man skal vente til i morgen for at se virkningen -// af, er en indstilling, ingen tør slå til. +// af, er en indstilling, ingen tør slå til — og fordi kortet på I dag skal kunne +// lave en plan for den, der har beskeden slået fra. func (s *Server) handlePlanNow(w http.ResponseWriter, r *http.Request) { + var req planNowRequest + if r.ContentLength > 0 { + if err := decodeJSON(w, r, &req); err != nil { + return + } + } user := userFrom(r.Context()) + plan, err := s.buildPlan(r.Context(), user) if err != nil { s.internal(w, r, "build plan", err) @@ -126,11 +179,36 @@ func (s *Server) handlePlanNow(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"plan": "", "sent": false}) return } - s.notify(r, &store.Notification{ - UserID: user.ID, Kind: "daily.plan", - Title: planTitle(user.Locale), Body: plan, + if err := s.rememberPlan(r.Context(), user, plan); err != nil { + s.internal(w, r, "save plan", err) + return + } + + sent := false + if !req.Silent { + s.notify(r, &store.Notification{ + UserID: user.ID, Kind: "daily.plan", + Title: planTitle(user.Locale), Body: plan, + }) + sent = true + } + writeJSON(w, http.StatusOK, map[string]any{ + "plan": plan, "sent": sent, + "plan_at": time.Now().Unix(), }) - writeJSON(w, http.StatusOK, map[string]any{"plan": plan, "sent": true}) +} + +// rememberPlan gemmer dagens plan, så den kan ses igen uden at blive lavet igen. +func (s *Server) rememberPlan(ctx context.Context, user *store.User, plan string) error { + p, err := s.planSettings(ctx, user.ID) + if err != nil { + return err + } + now := time.Now() + p.Plan = plan + p.PlanDate = now.In(userLocation(user.Timezone)).Format("2006-01-02") + p.PlanAt = now.Unix() + return s.savePlanSettings(ctx, user.ID, p) } // DailyPlans er timeslaget: hvem er der morgen hos, og hvem har ikke fået planen @@ -182,6 +260,10 @@ func (s *Server) DailyPlans(ctx context.Context) error { UserID: user.ID, Kind: "daily.plan", Title: planTitle(user.Locale), Body: plan, }) + // Gemt, og gemt her frem for i rememberPlan: rækken er allerede + // læst ind i `p`, og to gemninger oven i hinanden ville lade den + // ene overskrive den andens `last_sent`. + p.Plan, p.PlanDate, p.PlanAt = plan, today, time.Now().Unix() } p.LastSent = today if err := s.savePlanSettings(ctx, user.ID, p); err != nil { diff --git a/internal/httpapi/ai_plan_test.go b/internal/httpapi/ai_plan_test.go index d047fe1..7c3bce3 100644 --- a/internal/httpapi/ai_plan_test.go +++ b/internal/httpapi/ai_plan_test.go @@ -3,6 +3,7 @@ package httpapi import ( "net/http" "testing" + "time" ) // Planen er tal fra basen, ikke fra modellen. En plan, der siger fire opgaver, @@ -94,3 +95,63 @@ func TestAnImpossiblePlanHourIsRefused(t *testing.T) { t.Fatalf("time 25 gav %d, want 422", resp.StatusCode) } } + +// Planen gemmes for dagen, så kortet på I dag kan vise nøjagtig den tekst, +// beskeden bar — og ikke et nyt gæt, hver gang nogen kigger. +func TestTodaysPlanIsKeptForTheDay(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + ts.quickTask(t, "noget i dag") + + // Ingen plan endnu: kortet viser en knap frem for gårsdagens tekst. + if _, before := ts.do(t, "GET", "/api/v1/ai/plan", nil); before["plan"] != nil && before["plan"] != "" { + t.Fatalf("der lå en plan, før nogen havde lavet en: %v", before["plan"]) + } + + resp, made := ts.do(t, "POST", "/api/v1/ai/plan/now", map[string]any{"silent": true}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("lav planen: %d", resp.StatusCode) + } + if made["sent"] != false { + t.Errorf("en stille plan sendte en besked: %v", made["sent"]) + } + if list, _ := notifications(t, ts); len(list) != 0 { + t.Errorf("kortets egen plan gav %d beskeder, want 0", len(list)) + } + + _, after := ts.do(t, "GET", "/api/v1/ai/plan", nil) + if after["plan"] != made["plan"] { + t.Errorf("den gemte plan = %v, den lavede = %v", after["plan"], made["plan"]) + } + if after["plan_at"] == nil { + t.Error("planen bærer ikke, hvornår den blev lavet") + } +} + +// En plan fra i går er ikke dagens. Et kort, der viser gårsdagens plan under +// overskriften "i dag", er værre end et tomt kort. +func TestYesterdaysPlanIsNotTodays(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + ts.quickTask(t, "noget i dag") + + if resp, _ := ts.do(t, "POST", "/api/v1/ai/plan/now", map[string]any{"silent": true}); resp.StatusCode != http.StatusOK { + t.Fatal("lav planen") + } + + // Sat en dag tilbage, som om den var lavet i går. + user := userID(t, ts, "kristian@example.dk") + stored, err := ts.db.UserSettings(t.Context(), user, "dailyplan") + if err != nil { + t.Fatal(err) + } + stored["plan_date"] = time.Now().AddDate(0, 0, -1).Format("2006-01-02") + if err := ts.db.SetUserSettings(t.Context(), user, "dailyplan", stored); err != nil { + t.Fatal(err) + } + + _, out := ts.do(t, "GET", "/api/v1/ai/plan", nil) + if plan, _ := out["plan"].(string); plan != "" { + t.Errorf("gårsdagens plan blev vist som dagens: %q", plan) + } +} diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 0beff49..413accd 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -4325,3 +4325,38 @@ test('links kan åbnes i en anden browser, og åbner alligevel når skemaet ikke expect(trouble).toEqual([]); }); + +test('dagens plan står på I dag, og ikke kun som en besked', async ({ page }) => { + const trouble = watchForTrouble(page); + + await page.goto('/'); + const box = page.getByLabel('Ny opgave'); + await box.fill('ring til planlæggeren i dag'); + await box.press('Enter'); + await page.waitForTimeout(400); + + // Uden en model er planen to tal — og det er stadig det, man havde brug for at + // vide om morgenen. Knappen er der, fordi morgenbeskeden er slået fra som + // udgangspunkt: den, der ikke vil vækkes, skal stadig kunne få planen. + await page.getByRole('button', { name: 'Lav dagens plan' }).click(); + + const card = page.locator('.plan'); + await expect(card).toBeVisible(); + await expect(card).toContainText('forfalder i dag'); + // Klokkeslættet står på kortet: en plan fra klokken syv er en anden slags + // oplysning klokken fire end klokken otte. + await expect(card.locator('.made')).toBeVisible(); + + // Og den bliver stående — det er hele forskellen på et kort og en besked. + await page.reload(); + await expect(page.locator('.plan')).toContainText('forfalder i dag'); + + // Skjult for i dag, ikke slettet: kortet er en påmindelse, og den der har læst + // den skal kunne få den af vejen. + await page.locator('.plan .close').click(); + await expect(page.locator('.plan')).toBeHidden(); + await page.reload(); + await expect(page.locator('.plan')).toBeHidden(); + + expect(trouble).toEqual([]); +}); diff --git a/web/src/lib/api.js b/web/src/lib/api.js index 31d2ba1..8681893 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -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'), diff --git a/web/src/lib/components/Notifications.svelte b/web/src/lib/components/Notifications.svelte index 91e5eef..c092c3a 100644 --- a/web/src/lib/components/Notifications.svelte +++ b/web/src/lib/components/Notifications.svelte @@ -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 @@
  • @@ -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); diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js index e24cb7a..36b872d 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -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.', diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js index dcd3ef0..00437e7 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -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.', diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 428d875..dad5151 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -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 @@ + {#if plan?.plan && !dismissed} + +
    +
    +

    {t('ai.plan')}

    + {#if planMade}{t('ai.planMade', { time: planMade })}{/if} + +
    +

    {plan.plan}

    + +
    + {:else if plan && !plan.plan && (liveToday.length || liveOverdue.length)} + +

    + + {#if planError}{planError}{/if} +

    + {/if} + {#if liveOverdue.length}

    {t('view.overdue')}

    @@ -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); }