Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
861820b785 | ||
|
|
f06a25b43e | ||
|
|
6cf184caa6 | ||
|
|
3c14906db2 | ||
|
|
1cabbb7a78 |
+1537
File diff suppressed because it is too large
Load Diff
+15
-5
@@ -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
|
**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
|
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
|
has been missed, and what to start with — and the same text as a card at the top of
|
||||||
handed to the model as facts it may not recompute — a plan that says four tasks
|
**I dag**, where you are looking anyway when you start the day. The notification is
|
||||||
where there are three is worse than no plan. With no model configured the counts
|
the reminder; the card is where it stays.
|
||||||
stand on their own, which is what you needed to know at seven in the morning anyway.
|
|
||||||
Nothing due and nothing overdue sends nothing.
|
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
|
**Mail that becomes a task you can act on.** Where a
|
||||||
[mailbox](mailboxes.md) used to produce *"Anders Jensen: SV: SV: Vedr. levering uge
|
[mailbox](mailboxes.md) used to produce *"Anders Jensen: SV: SV: Vedr. levering uge
|
||||||
|
|||||||
@@ -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 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 gave you a task | Only on the change, and only to the person who got it |
|
||||||
| Somebody commented | |
|
| 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 |
|
| A new version is out | Administrators only, once per version |
|
||||||
|
|
||||||
Not your own actions: the server does not tell anybody what they just did
|
Not your own actions: the server does not tell anybody what they just did
|
||||||
|
|||||||
+29
-3
@@ -3688,7 +3688,21 @@ paths:
|
|||||||
/api/v1/ai/plan/now:
|
/api/v1/ai/plan/now:
|
||||||
post:
|
post:
|
||||||
tags: [integrations]
|
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: |
|
description: |
|
||||||
The counts come from the database and the sentence from the model, in that
|
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
|
order: a model that counted for itself would sometimes count wrong, and a
|
||||||
@@ -4545,8 +4559,9 @@ components:
|
|||||||
DailyPlanSettings:
|
DailyPlanSettings:
|
||||||
type: object
|
type: object
|
||||||
description: >
|
description: >
|
||||||
One person's morning plan. Off by default — a notification at seven in the
|
One person's morning plan, and today's plan itself. One resource, because
|
||||||
morning that nobody asked for is not a help.
|
it is one thing: the plan is the resource and the setting is when it gets
|
||||||
|
made.
|
||||||
properties:
|
properties:
|
||||||
enabled: { type: boolean }
|
enabled: { type: boolean }
|
||||||
hour:
|
hour:
|
||||||
@@ -4555,6 +4570,17 @@ components:
|
|||||||
last_sent:
|
last_sent:
|
||||||
type: string
|
type: string
|
||||||
description: The date it last went out, in that person's own timezone.
|
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:
|
AISuggestion:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -39,6 +39,19 @@ type planSettings struct {
|
|||||||
// tidszone. En dato og ikke et tidsstempel, fordi spørgsmålet er "har de fået
|
// 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.
|
// dagens plan i dag" — og det er et spørgsmål om dagen dér, hvor de er.
|
||||||
LastSent string `json:"last_sent,omitempty"`
|
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) {
|
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 {
|
if v, ok := stored["last_sent"].(string); ok {
|
||||||
out.LastSent = v
|
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
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) savePlanSettings(ctx context.Context, userID string, p planSettings) error {
|
func (s *Server) savePlanSettings(ctx context.Context, userID string, p planSettings) error {
|
||||||
return s.db.SetUserSettings(ctx, userID, planScope, map[string]any{
|
return s.db.SetUserSettings(ctx, userID, planScope, map[string]any{
|
||||||
"enabled": p.Enabled, "hour": p.Hour, "last_sent": p.LastSent,
|
"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) {
|
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 {
|
if err != nil {
|
||||||
s.internal(w, r, "plan settings", err)
|
s.internal(w, r, "plan settings", err)
|
||||||
return
|
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)
|
writeJSON(w, http.StatusOK, p)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,12 +147,29 @@ func (s *Server) handleSetPlanSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, p)
|
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
|
// 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) {
|
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())
|
user := userFrom(r.Context())
|
||||||
|
|
||||||
plan, err := s.buildPlan(r.Context(), user)
|
plan, err := s.buildPlan(r.Context(), user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, r, "build plan", err)
|
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})
|
writeJSON(w, http.StatusOK, map[string]any{"plan": "", "sent": false})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.notify(r, &store.Notification{
|
if err := s.rememberPlan(r.Context(), user, plan); err != nil {
|
||||||
UserID: user.ID, Kind: "daily.plan",
|
s.internal(w, r, "save plan", err)
|
||||||
Title: planTitle(user.Locale), Body: plan,
|
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
|
// 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",
|
UserID: user.ID, Kind: "daily.plan",
|
||||||
Title: planTitle(user.Locale), Body: 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
|
p.LastSent = today
|
||||||
if err := s.savePlanSettings(ctx, user.ID, p); err != nil {
|
if err := s.savePlanSettings(ctx, user.ID, p); err != nil {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package httpapi
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Planen er tal fra basen, ikke fra modellen. En plan, der siger fire opgaver,
|
// 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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4325,3 +4325,38 @@ test('links kan åbnes i en anden browser, og åbner alligevel når skemaet ikke
|
|||||||
|
|
||||||
expect(trouble).toEqual([]);
|
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([]);
|
||||||
|
});
|
||||||
|
|||||||
+3
-1
@@ -496,7 +496,9 @@ export const api = {
|
|||||||
// Dagens plan om morgenen.
|
// Dagens plan om morgenen.
|
||||||
planSettings: () => get('/ai/plan'),
|
planSettings: () => get('/ai/plan'),
|
||||||
setPlanSettings: (body) => put('/ai/plan', body),
|
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'),
|
version: () => get('/version'),
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,9 @@
|
|||||||
// En ny version hører ingen steder hen i data — den fører hen, hvor knappen
|
// En ny version hører ingen steder hen i data — den fører hen, hvor knappen
|
||||||
// til at gøre noget ved den sidder.
|
// til at gøre noget ved den sidder.
|
||||||
if (n.kind === 'update.available') return goto('/indstillinger/notifikationer');
|
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}`);
|
if (n.note_id) goto(`/noter?note=${n.note_id}`);
|
||||||
else if (n.task_id) app.openDetail(n.task_id);
|
else if (n.task_id) app.openDetail(n.task_id);
|
||||||
else if (n.project_id) goto(`/projekt/${n.project_id}`);
|
else if (n.project_id) goto(`/projekt/${n.project_id}`);
|
||||||
@@ -111,7 +114,10 @@
|
|||||||
<li>
|
<li>
|
||||||
<button class="row" class:unread={!n.read} onclick={() => follow(n)}>
|
<button class="row" class:unread={!n.read} onclick={() => follow(n)}>
|
||||||
<span class="what">{headline(n)}</span>
|
<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>
|
<span class="ago">{when(n.created_at)}</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -260,6 +266,12 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.about.full {
|
||||||
|
white-space: pre-line;
|
||||||
|
text-overflow: clip;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.ago {
|
.ago {
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ export const da = {
|
|||||||
'ai.planHint':
|
'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.',
|
'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.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.planNow': 'Send den nu',
|
||||||
'ai.planSent': 'Sendt — kig i klokken.',
|
'ai.planSent': 'Sendt — kig i klokken.',
|
||||||
'ai.planNothing': 'Der er ikke noget at planlægge i dag.',
|
'ai.planNothing': 'Der er ikke noget at planlægge i dag.',
|
||||||
|
|||||||
@@ -93,6 +93,10 @@ export const en = {
|
|||||||
'ai.planHint':
|
'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.',
|
'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.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.planNow': 'Send it now',
|
||||||
'ai.planSent': 'Sent — look in the bell.',
|
'ai.planSent': 'Sent — look in the bell.',
|
||||||
'ai.planNothing': 'There is nothing to plan for today.',
|
'ai.planNothing': 'There is nothing to plan for today.',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import TaskRow from '$lib/components/TaskRow.svelte';
|
import TaskRow from '$lib/components/TaskRow.svelte';
|
||||||
import QuickAdd from '$lib/components/QuickAdd.svelte';
|
import QuickAdd from '$lib/components/QuickAdd.svelte';
|
||||||
import { t, tag } from '$lib/i18n.svelte.js';
|
import { t, tag } from '$lib/i18n.svelte.js';
|
||||||
|
import { humanMessage } from '$lib/api.js';
|
||||||
|
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
|
|
||||||
@@ -17,6 +18,72 @@
|
|||||||
).padStart(2, '0')}`;
|
).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() {
|
async function load() {
|
||||||
const data = await api.today();
|
const data = await api.today();
|
||||||
app.tasks = [...data.overdue, ...data.today];
|
app.tasks = [...data.overdue, ...data.today];
|
||||||
@@ -54,6 +121,33 @@
|
|||||||
|
|
||||||
<QuickAdd />
|
<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}
|
{#if liveOverdue.length}
|
||||||
<section>
|
<section>
|
||||||
<h2 class="overdue">{t('view.overdue')}</h2>
|
<h2 class="overdue">{t('view.overdue')}</h2>
|
||||||
@@ -93,6 +187,77 @@
|
|||||||
margin-bottom: var(--s5);
|
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 {
|
h1 {
|
||||||
font-size: var(--text-2xl);
|
font-size: var(--text-2xl);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user