Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
378980de0c | ||
|
|
04c7a52a5a | ||
|
|
52df528994 | ||
|
|
2f00246d6f | ||
|
|
acec77a20c | ||
|
|
59413a8b64 | ||
|
|
cf69cac410 | ||
|
|
b0998fe524 | ||
|
|
8dfab618f7 | ||
|
|
c0c1281415 | ||
|
|
247277e139 | ||
|
|
f3b037c7f4 | ||
|
|
4103e1b0ca | ||
|
|
9d4a13c2c2 | ||
|
|
923f911472 | ||
|
|
272c4918b0 | ||
|
|
ff866cc514 | ||
|
|
e94d731c30 | ||
|
|
0aa0ec2989 | ||
|
|
21b60b007a | ||
|
|
7066e2e06e | ||
|
|
2b76a172e8 | ||
|
|
15495255ef | ||
|
|
3b9b68f4b1 | ||
|
|
7dcd475036 | ||
|
|
fc4877c3bd | ||
|
|
2da679d629 | ||
|
|
9e3f20b050 | ||
|
|
fa816b2e41 | ||
|
|
60d66b728e | ||
|
|
bae3b3505f | ||
|
|
7b1e7ba7c9 | ||
|
|
c0dbea10c5 |
@@ -24,9 +24,10 @@ Documentation: [`docs/`](docs/), built with Material for MkDocs.
|
|||||||
|
|
||||||
## Why this exists
|
## Why this exists
|
||||||
|
|
||||||
Todoist Pro, self-hosted, on your own hardware, with your own data — and with the
|
A fast, self-hosted task manager — projects, quick add, filters, notes and shared
|
||||||
integrations that a hosted product will not build for you: a real CalDAV server, a
|
lists — on your own hardware, with your own data, and with the integrations that a
|
||||||
mail-to-task address, and an MCP endpoint so Claude can work your task list directly.
|
hosted product will not build for you: a real CalDAV server, a mail-to-task address,
|
||||||
|
and an MCP endpoint so Claude can work your task list directly.
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,16 @@ func run() error {
|
|||||||
log.Info("notes sealed", "count", sealed)
|
log.Info("notes sealed", "count", sealed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Names created before the app capitalised them on the way in — "andreas"
|
||||||
|
// rather than "Andreas". Idempotent, so it is a cheap pass of names once it has
|
||||||
|
// caught up. A failure here does not stop the start: a lower-case first name is
|
||||||
|
// a blemish, not a broken instance.
|
||||||
|
if fixed, err := db.NormalizeUserNames(context.Background()); err != nil {
|
||||||
|
log.Error("normalize user names", "err", err)
|
||||||
|
} else if fixed > 0 {
|
||||||
|
log.Info("user names normalized", "count", fixed)
|
||||||
|
}
|
||||||
|
|
||||||
httpapi.Version = version
|
httpapi.Version = version
|
||||||
api := httpapi.New(cfg, db, log, webAssets(log))
|
api := httpapi.New(cfg, db, log, webAssets(log))
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Saved filters
|
# Saved filters
|
||||||
|
|
||||||
A filter is a small expression describing a slice of your tasks. The grammar is
|
A filter is a small expression describing a slice of your tasks. The grammar will
|
||||||
Todoist's, so if you are coming from there you already know it.
|
feel familiar if you have used query-based filters in other task apps.
|
||||||
|
|
||||||
```
|
```
|
||||||
today & p1
|
today & p1
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ of. Which is what a to-do list is.
|
|||||||
|
|
||||||
## What it is for
|
## What it is for
|
||||||
|
|
||||||
Todoist Pro, on your own hardware, with your own data. And with the integrations a
|
A fast, self-hosted task manager — projects, quick add, filters, notes and shared
|
||||||
|
lists — on your own hardware, with your own data. And with the integrations a
|
||||||
hosted product will not build for you: a real CalDAV server, an address you can
|
hosted product will not build for you: a real CalDAV server, an address you can
|
||||||
forward mail to, and an MCP endpoint so Claude can work your task list directly.
|
forward mail to, and an MCP endpoint so Claude can work your task list directly.
|
||||||
|
|
||||||
@@ -22,15 +23,22 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
|
|||||||
Firma, labelled regnskab. Danish and English, mixed freely in one line.
|
Firma, labelled regnskab. Danish and English, mixed freely in one line.
|
||||||
- **Projects, sections, sub-tasks, labels, [saved filters](filters.md).** List,
|
- **Projects, sections, sub-tasks, labels, [saved filters](filters.md).** List,
|
||||||
board and calendar views, and foldable groups over the projects in the sidebar.
|
board and calendar views, and foldable groups over the projects in the sidebar.
|
||||||
- **Notes, beside the work.** Rich text to write in, Markdown on disk. A note is
|
The calendar draws each task to its length — drag the foot to resize, the body
|
||||||
shared by filing it in a project; `#project` and `[[another note]]` are links,
|
to move it.
|
||||||
and a task shows what has been written about it.
|
- **Notes, beside the work.** Rich text to write in, Markdown on disk. Share a
|
||||||
|
single note with a person, or file it in a project; `#project` and
|
||||||
|
`[[another note]]` are links, and a task shows what has been written about it.
|
||||||
|
- **Delegate, and snooze.** Hand a task to somebody and it is marked with an
|
||||||
|
arrow to them, gathered on a *Waiting on others* page. Snooze one to park it,
|
||||||
|
greyed, at the foot of the list until a moment you pick — without touching when
|
||||||
|
it is due.
|
||||||
- **Drag where it means something** — reorder a list or a board, file a project
|
- **Drag where it means something** — reorder a list or a board, file a project
|
||||||
under a group, drop a task on another project, or drop it on another day.
|
under a group, drop a task on another project, or drop it on another day.
|
||||||
- **Sharing** with owner, editor and viewer roles. Anyone outside your instance
|
- **Sharing** with owner, editor and viewer roles. Anyone outside your instance
|
||||||
joins through an invite link.
|
joins through an invite link.
|
||||||
- **Live sync** — a change made by somebody else appears without a refresh.
|
- **Live sync** — a change made by somebody else appears without a refresh.
|
||||||
- **Repeating tasks** as RRULE, reminders, comments and attachments.
|
- **Repeating tasks** as RRULE, comments and attachments, and reminders that go
|
||||||
|
out as Web Push to an iPhone or a Mac.
|
||||||
- **[Calendar](caldav.md), [mail](mail.md) and [Claude](mcp.md)** connected over
|
- **[Calendar](caldav.md), [mail](mail.md) and [Claude](mcp.md)** connected over
|
||||||
open standards rather than private formats.
|
open standards rather than private formats.
|
||||||
|
|
||||||
@@ -48,7 +56,9 @@ columns.*
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
*What you have handed to somebody else, grouped by whoever has it.*
|
*What you have handed to somebody else, grouped by whoever has it — and each row
|
||||||
|
marked with an arrow to them, so a delegated task reads as delegated wherever you
|
||||||
|
meet it.*
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@@ -805,6 +805,28 @@ paths:
|
|||||||
"200": { $ref: "#/components/responses/Task" }
|
"200": { $ref: "#/components/responses/Task" }
|
||||||
"404": { $ref: "#/components/responses/NotFound" }
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
|
||||||
|
/api/v1/tasks/{taskID}/snooze:
|
||||||
|
parameters:
|
||||||
|
- { name: taskID, in: path, required: true, schema: { type: string } }
|
||||||
|
post:
|
||||||
|
tags: [tasks]
|
||||||
|
summary: Snooze a task until a time, or wake it.
|
||||||
|
description: |
|
||||||
|
Parks the task at the bottom of its list, greyed, until `until` passes —
|
||||||
|
nothing else about it moves. An empty `until` wakes it now.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
until: { type: string, format: date-time, description: "RFC3339; empty to wake." }
|
||||||
|
responses:
|
||||||
|
"200": { $ref: "#/components/responses/Task" }
|
||||||
|
"404": { $ref: "#/components/responses/NotFound" }
|
||||||
|
"422": { $ref: "#/components/responses/Validation" }
|
||||||
|
|
||||||
/api/v1/tasks/quick-add:
|
/api/v1/tasks/quick-add:
|
||||||
post:
|
post:
|
||||||
tags: [tasks]
|
tags: [tasks]
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 240 KiB After Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 303 KiB After Width: | Height: | Size: 306 KiB |
|
Before Width: | Height: | Size: 218 KiB After Width: | Height: | Size: 223 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 315 KiB After Width: | Height: | Size: 331 KiB |
|
Before Width: | Height: | Size: 237 KiB After Width: | Height: | Size: 243 KiB |
|
Before Width: | Height: | Size: 261 KiB After Width: | Height: | Size: 263 KiB |
|
Before Width: | Height: | Size: 344 KiB After Width: | Height: | Size: 350 KiB |
|
Before Width: | Height: | Size: 328 KiB After Width: | Height: | Size: 333 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 227 KiB |
|
Before Width: | Height: | Size: 244 KiB After Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 235 KiB After Width: | Height: | Size: 241 KiB |
|
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 241 KiB |
|
Before Width: | Height: | Size: 194 KiB After Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 216 KiB After Width: | Height: | Size: 222 KiB |
@@ -1,7 +1,8 @@
|
|||||||
# Self-hosting
|
# With Docker
|
||||||
|
|
||||||
verdande is one static binary and one directory. There is no database server, no
|
Running it yourself outside a panel: one static binary and one directory. There is
|
||||||
cache, no queue and no second process.
|
no database server, no cache, no queue and no second process. (Yggdrasil is
|
||||||
|
self-hosting too — for that route see [As a Rune](rune.md).)
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
|
|||||||
@@ -148,15 +148,23 @@ func (s *Server) pushToUser(userID, title, body, projectID string) pushOutcome {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := push.Payload{Title: title, Body: body, URL: "/projekt/" + projectID}
|
target := "/projekt/" + projectID
|
||||||
|
if projectID == "" {
|
||||||
|
target = "/"
|
||||||
|
}
|
||||||
|
payload := push.Payload{Title: title, Body: body, URL: target}
|
||||||
|
subject := s.vapidSubject()
|
||||||
for _, sub := range subs {
|
for _, sub := range subs {
|
||||||
err := push.Send(ctx, push.Subscription{
|
err := push.Send(ctx, push.Subscription{
|
||||||
Endpoint: sub.Endpoint, P256dh: sub.P256dh, Auth: sub.Auth,
|
Endpoint: sub.Endpoint, P256dh: sub.P256dh, Auth: sub.Auth,
|
||||||
}, payload, push.VAPID{
|
}, payload, push.VAPID{
|
||||||
Public: public, Private: private, Subject: "mailto:" + s.cfg.SMTP.From,
|
Public: public, Private: private, Subject: subject,
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
out.Sent++
|
out.Sent++
|
||||||
|
if delErr := s.db.MarkPushDelivered(ctx, sub.Endpoint); delErr != nil {
|
||||||
|
s.log.Warn("mark push delivered", "err", delErr)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Warn, not Debug.
|
// Warn, not Debug.
|
||||||
@@ -257,6 +265,29 @@ func (s *Server) vapidKeys(ctx contextLike) (string, string, error) {
|
|||||||
return s.db.InstanceKeys(ctx, push.GenerateVAPIDKeys)
|
return s.db.InstanceKeys(ctx, push.GenerateVAPIDKeys)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// vapidSubject is the "sub" claim a push service is told to reach the sender at.
|
||||||
|
//
|
||||||
|
// It has to be one the service will actually accept, and Apple is the strict one:
|
||||||
|
// it answers 403 Forbidden to a mailto whose domain is made up — which is exactly
|
||||||
|
// what the old fallback, mailto:verdande@example.invalid, was on an instance with
|
||||||
|
// no mail set up. Every push to an iPhone or a Mac was rejected, silently as far as
|
||||||
|
// the person waiting for the notification could tell.
|
||||||
|
//
|
||||||
|
// So a real address is used when there is one, and otherwise the instance's own
|
||||||
|
// https address — which is a valid VAPID subject, points at something that exists,
|
||||||
|
// and Apple accepts. Verified against web.push.apple.com: the mailto gets a 403,
|
||||||
|
// the https URL a 201.
|
||||||
|
func (s *Server) vapidSubject() string {
|
||||||
|
from := strings.TrimSpace(s.cfg.SMTP.From)
|
||||||
|
if from != "" && strings.Contains(from, "@") && !strings.HasSuffix(from, "@localhost") {
|
||||||
|
return "mailto:" + from
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(s.cfg.BaseURL, "https://") || strings.HasPrefix(s.cfg.BaseURL, "http://") {
|
||||||
|
return s.cfg.BaseURL
|
||||||
|
}
|
||||||
|
return "mailto:verdande@example.invalid"
|
||||||
|
}
|
||||||
|
|
||||||
// --- subscription endpoints -----------------------------------------------------------
|
// --- subscription endpoints -----------------------------------------------------------
|
||||||
|
|
||||||
type pushSubscriptionRequest struct {
|
type pushSubscriptionRequest struct {
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kristianwind/verdande/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The VAPID subject has to be one a push service will accept. Apple answers 403 to
|
||||||
|
// a mailto with a made-up domain, which is what the no-mail fallback used to be, so
|
||||||
|
// every push to an Apple device was refused. With no real address, the instance's
|
||||||
|
// own https URL is used instead — valid, real, and accepted.
|
||||||
|
func TestVAPIDSubject(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
from string
|
||||||
|
baseURL string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"a real address is used as-is", "todo@example.dk", "https://verdande.example.dk", "mailto:todo@example.dk"},
|
||||||
|
{"no mail falls back to the https url", "", "https://verdande.example.dk", "https://verdande.example.dk"},
|
||||||
|
{"the localhost default is not a real address", "verdande@localhost", "https://verdande.example.dk", "https://verdande.example.dk"},
|
||||||
|
{"a plain http dev url still serves as a subject", "", "http://localhost:8080", "http://localhost:8080"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
s := &Server{cfg: &config.Config{BaseURL: tc.baseURL, SMTP: config.SMTP{From: tc.from}}}
|
||||||
|
if got := s.vapidSubject(); got != tc.want {
|
||||||
|
t.Errorf("vapidSubject() = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -551,6 +551,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Post("/complete", s.handleCompleteTask)
|
r.Post("/complete", s.handleCompleteTask)
|
||||||
r.Post("/reopen", s.handleReopenTask)
|
r.Post("/reopen", s.handleReopenTask)
|
||||||
r.Post("/move", s.handleMoveTask)
|
r.Post("/move", s.handleMoveTask)
|
||||||
|
r.Post("/snooze", s.handleSnoozeTask)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,23 +16,24 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type taskJSON struct {
|
type taskJSON struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ProjectID string `json:"project_id"`
|
ProjectID string `json:"project_id"`
|
||||||
SectionID string `json:"section_id,omitempty"`
|
SectionID string `json:"section_id,omitempty"`
|
||||||
ParentID string `json:"parent_id,omitempty"`
|
ParentID string `json:"parent_id,omitempty"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
Priority int `json:"priority"`
|
Priority int `json:"priority"`
|
||||||
DueDate string `json:"due_date,omitempty"`
|
DueDate string `json:"due_date,omitempty"`
|
||||||
DueDatetime string `json:"due_datetime,omitempty"`
|
DueDatetime string `json:"due_datetime,omitempty"`
|
||||||
DurationMin *int `json:"duration_min,omitempty"`
|
DurationMin *int `json:"duration_min,omitempty"`
|
||||||
Recurrence string `json:"recurrence_rule,omitempty"`
|
Recurrence string `json:"recurrence_rule,omitempty"`
|
||||||
RepeatText string `json:"recurrence_text,omitempty"`
|
RepeatText string `json:"recurrence_text,omitempty"`
|
||||||
AssigneeID string `json:"assignee_id,omitempty"`
|
AssigneeID string `json:"assignee_id,omitempty"`
|
||||||
Labels []string `json:"labels"`
|
Labels []string `json:"labels"`
|
||||||
Completed bool `json:"completed"`
|
Completed bool `json:"completed"`
|
||||||
CompletedAt string `json:"completed_at,omitempty"`
|
CompletedAt string `json:"completed_at,omitempty"`
|
||||||
CreatedBy string `json:"created_by"`
|
SnoozedUntil string `json:"snoozed_until,omitempty"`
|
||||||
|
CreatedBy string `json:"created_by"`
|
||||||
// What the row can say without being opened. Sent with every task rather than
|
// What the row can say without being opened. Sent with every task rather than
|
||||||
// fetched when one is opened, because the point is that you see it in the list.
|
// fetched when one is opened, because the point is that you see it in the list.
|
||||||
SubtaskCount int `json:"subtask_count,omitempty"`
|
SubtaskCount int `json:"subtask_count,omitempty"`
|
||||||
@@ -67,6 +68,9 @@ func toTaskJSON(t store.Task) taskJSON {
|
|||||||
if t.CompletedAt != nil {
|
if t.CompletedAt != nil {
|
||||||
j.CompletedAt = t.CompletedAt.Format(time.RFC3339)
|
j.CompletedAt = t.CompletedAt.Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
|
if t.SnoozedUntil != nil {
|
||||||
|
j.SnoozedUntil = t.SnoozedUntil.Format(time.RFC3339)
|
||||||
|
}
|
||||||
return j
|
return j
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -698,3 +702,52 @@ func parseLimit(raw string, def, max int) int {
|
|||||||
}
|
}
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type snoozeRequest struct {
|
||||||
|
// Until is an RFC3339 time to snooze to, or empty to wake the task now.
|
||||||
|
Until string `json:"until"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSnoozeTask parks a task until a time, or wakes it. Snooze changes nothing
|
||||||
|
// but when the task wants to be seen — its due date and everything else stay put.
|
||||||
|
func (s *Server) handleSnoozeTask(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req snoozeRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
taskID := chi.URLParam(r, "taskID")
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
|
||||||
|
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
||||||
|
if err != nil || !role.CanEdit() {
|
||||||
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var until *time.Time
|
||||||
|
if strings.TrimSpace(req.Until) != "" {
|
||||||
|
parsed, perr := time.Parse(time.RFC3339, req.Until)
|
||||||
|
if perr != nil {
|
||||||
|
writeFieldErrors(w, map[string]string{"until": "must be an RFC3339 time"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
until = &parsed
|
||||||
|
}
|
||||||
|
if err := s.db.SnoozeTask(r.Context(), taskID, until); err != nil {
|
||||||
|
s.storeError(w, r, "snooze task", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.storeError(w, r, "get task", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
verb := "task.snoozed"
|
||||||
|
if until == nil {
|
||||||
|
verb = "task.unsnoozed"
|
||||||
|
}
|
||||||
|
s.activity(r, t.ProjectID, t.ID, verb, nil)
|
||||||
|
s.publish(t.ProjectID, "task.updated", toTaskJSON(*t))
|
||||||
|
writeJSON(w, http.StatusOK, toTaskJSON(*t))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1817,3 +1817,50 @@ func TestATaskIsFoundByAWordInsideAWord(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A snoozed task sinks to the bottom of the list and greys until its time; waking
|
||||||
|
// it puts it back. The list order is what the row's dimming rides on.
|
||||||
|
func TestSnoozeSinksAndWakes(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, a := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "Alfa"})
|
||||||
|
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "Bravo"})
|
||||||
|
aID, _ := a["id"].(string)
|
||||||
|
|
||||||
|
// Snooze Alfa two days out.
|
||||||
|
future := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339)
|
||||||
|
resp, body := ts.do(t, "POST", "/api/v1/tasks/"+aID+"/snooze", map[string]any{"until": future})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("snooze: %d %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
if body["snoozed_until"] == "" || body["snoozed_until"] == nil {
|
||||||
|
t.Error("the response does not carry snoozed_until")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alfa now sorts after Bravo, though it was created first.
|
||||||
|
order := func() []string {
|
||||||
|
_, list := ts.do(t, "GET", "/api/v1/tasks", nil)
|
||||||
|
var out []string
|
||||||
|
for _, raw := range list["tasks"].([]any) {
|
||||||
|
out = append(out, raw.(map[string]any)["content"].(string))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
got := order()
|
||||||
|
if len(got) != 2 || got[0] != "Bravo" || got[1] != "Alfa" {
|
||||||
|
t.Errorf("snoozed task did not sink: order = %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wake it, and it comes back above Bravo.
|
||||||
|
resp, body = ts.do(t, "POST", "/api/v1/tasks/"+aID+"/snooze", map[string]any{"until": ""})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("wake: %d %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
if s, _ := body["snoozed_until"].(string); s != "" {
|
||||||
|
t.Errorf("waking left snoozed_until set: %q", s)
|
||||||
|
}
|
||||||
|
if got := order(); got[0] != "Alfa" {
|
||||||
|
t.Errorf("woken task did not return: order = %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -62,8 +62,13 @@ func Render(cal Calendar) string {
|
|||||||
|
|
||||||
for _, t := range cal.Tasks {
|
for _, t := range cal.Tasks {
|
||||||
writeTodo(&b, t, cal.Domain)
|
writeTodo(&b, t, cal.Domain)
|
||||||
// A dated task also goes out as an event, for clients that ignore VTODO.
|
// A dated task also goes out as an event, for the calendar apps that ignore
|
||||||
if t.DueDatetime != nil && !t.Completed {
|
// VTODO — which is all of them: Apple Calendar and Google both drop a
|
||||||
|
// subscribed to-do on the floor and show only events. A task with a clock
|
||||||
|
// time becomes a timed event; one with only a date, an all-day event. Once
|
||||||
|
// this was limited to timed tasks, so a date-only task — most of them —
|
||||||
|
// simply never appeared in the calendar it was subscribed into.
|
||||||
|
if !t.Completed && (t.DueDatetime != nil || t.DueDate != "") {
|
||||||
writeEvent(&b, t, cal.Domain)
|
writeEvent(&b, t, cal.Domain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,22 +133,31 @@ func writeTodo(b *strings.Builder, t Task, domain string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeEvent(b *strings.Builder, t Task, domain string) {
|
func writeEvent(b *strings.Builder, t Task, domain string) {
|
||||||
start := *t.DueDatetime
|
|
||||||
// Without a stated duration a task is shown as half an hour. A zero-length
|
|
||||||
// event collapses to an invisible sliver in most calendar grids.
|
|
||||||
minutes := 30
|
|
||||||
if t.DurationMin != nil && *t.DurationMin > 0 {
|
|
||||||
minutes = *t.DurationMin
|
|
||||||
}
|
|
||||||
end := start.Add(time.Duration(minutes) * time.Minute)
|
|
||||||
|
|
||||||
b.WriteString("BEGIN:VEVENT\r\n")
|
b.WriteString("BEGIN:VEVENT\r\n")
|
||||||
// A distinct UID suffix: the event and the to-do describe the same task, and
|
// A distinct UID suffix: the event and the to-do describe the same task, and
|
||||||
// sharing a UID would make a client treat them as two versions of one item.
|
// sharing a UID would make a client treat them as two versions of one item.
|
||||||
writeLine(b, "UID:"+t.ID+"-event@"+domain)
|
writeLine(b, "UID:"+t.ID+"-event@"+domain)
|
||||||
writeLine(b, "DTSTAMP:"+utc(t.UpdatedAt))
|
writeLine(b, "DTSTAMP:"+utc(t.UpdatedAt))
|
||||||
writeLine(b, "DTSTART:"+utc(start))
|
|
||||||
writeLine(b, "DTEND:"+utc(end))
|
if t.DueDatetime != nil {
|
||||||
|
start := *t.DueDatetime
|
||||||
|
// Without a stated duration a task is shown as half an hour. A zero-length
|
||||||
|
// event collapses to an invisible sliver in most calendar grids.
|
||||||
|
minutes := 30
|
||||||
|
if t.DurationMin != nil && *t.DurationMin > 0 {
|
||||||
|
minutes = *t.DurationMin
|
||||||
|
}
|
||||||
|
writeLine(b, "DTSTART:"+utc(start))
|
||||||
|
writeLine(b, "DTEND:"+utc(start.Add(time.Duration(minutes)*time.Minute)))
|
||||||
|
} else {
|
||||||
|
// A date with no time is an all-day event. DTEND is exclusive — a one-day
|
||||||
|
// event ends on the following date — which is what keeps it a single day in
|
||||||
|
// the grid rather than bleeding into the next.
|
||||||
|
day := strings.ReplaceAll(t.DueDate, "-", "")
|
||||||
|
writeLine(b, "DTSTART;VALUE=DATE:"+day)
|
||||||
|
writeLine(b, "DTEND;VALUE=DATE:"+nextDay(t.DueDate))
|
||||||
|
}
|
||||||
|
|
||||||
writeLine(b, "SUMMARY:"+escape(t.Content))
|
writeLine(b, "SUMMARY:"+escape(t.Content))
|
||||||
if t.Description != "" {
|
if t.Description != "" {
|
||||||
writeLine(b, "DESCRIPTION:"+escape(t.Description))
|
writeLine(b, "DESCRIPTION:"+escape(t.Description))
|
||||||
@@ -157,6 +171,17 @@ func writeEvent(b *strings.Builder, t Task, domain string) {
|
|||||||
|
|
||||||
func utc(t time.Time) string { return t.UTC().Format("20060102T150405Z") }
|
func utc(t time.Time) string { return t.UTC().Format("20060102T150405Z") }
|
||||||
|
|
||||||
|
// nextDay is the YYYYMMDD after a YYYY-MM-DD date, for an all-day event's exclusive
|
||||||
|
// DTEND. A malformed date is returned as its own compacted form rather than
|
||||||
|
// dropped, so a task never loses its event over a bad string.
|
||||||
|
func nextDay(date string) string {
|
||||||
|
d, err := time.Parse("2006-01-02", date)
|
||||||
|
if err != nil {
|
||||||
|
return strings.ReplaceAll(date, "-", "")
|
||||||
|
}
|
||||||
|
return d.AddDate(0, 0, 1).Format("20060102")
|
||||||
|
}
|
||||||
|
|
||||||
// escape applies RFC 5545 text escaping. A comma or semicolon in a task title is
|
// escape applies RFC 5545 text escaping. A comma or semicolon in a task title is
|
||||||
// a value separator in iCalendar, so an unescaped one silently truncates the
|
// a value separator in iCalendar, so an unescaped one silently truncates the
|
||||||
// summary at that character in every client.
|
// summary at that character in every client.
|
||||||
|
|||||||
@@ -102,19 +102,28 @@ func TestWholeDayDueDatesAreDates(t *testing.T) {
|
|||||||
|
|
||||||
// Google Calendar ignores VTODO. Timed tasks are therefore also emitted as events,
|
// Google Calendar ignores VTODO. Timed tasks are therefore also emitted as events,
|
||||||
// or a feed is useless in the calendar most people actually keep.
|
// or a feed is useless in the calendar most people actually keep.
|
||||||
func TestTimedTasksAlsoBecomeEvents(t *testing.T) {
|
func TestDatedTasksBecomeEvents(t *testing.T) {
|
||||||
out := Render(sample())
|
out := Render(sample())
|
||||||
|
|
||||||
if strings.Count(out, "BEGIN:VEVENT") != 1 {
|
// Every dated, unfinished task becomes an event, so it shows in a calendar app
|
||||||
t.Fatalf("got %d VEVENTs, want one for the single timed task",
|
// that ignores VTODO — timed t1, and date-only t2 and t3. The finished t4 has no
|
||||||
strings.Count(out, "BEGIN:VEVENT"))
|
// date and no event.
|
||||||
|
if got := strings.Count(out, "BEGIN:VEVENT"); got != 3 {
|
||||||
|
t.Fatalf("got %d VEVENTs, want 3 (one timed, two all-day)", got)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, "DTSTART:20260310T090000Z") {
|
|
||||||
t.Error("the event has no start")
|
// The timed one keeps its clock and its 45-minute duration.
|
||||||
|
if !strings.Contains(out, "DTSTART:20260310T090000Z") || !strings.Contains(out, "DTEND:20260310T094500Z") {
|
||||||
|
t.Error("the timed event's start or duration is wrong")
|
||||||
}
|
}
|
||||||
// 45 minutes of stated duration.
|
// The date-only ones are all-day: a DATE start and an exclusive next-day end, so
|
||||||
if !strings.Contains(out, "DTEND:20260310T094500Z") {
|
// they sit on their own day rather than bleeding into the next.
|
||||||
t.Error("the event's end does not reflect the task's duration")
|
if !strings.Contains(out, "DTSTART;VALUE=DATE:20260312") || !strings.Contains(out, "DTEND;VALUE=DATE:20260313") {
|
||||||
|
t.Error("the date-only event is not a single all-day event")
|
||||||
|
}
|
||||||
|
// A recurring date-only task carries its rule onto the event.
|
||||||
|
if !strings.Contains(out, "RRULE:FREQ=WEEKLY;BYDAY=MO") {
|
||||||
|
t.Error("the recurring task's event has no RRULE")
|
||||||
}
|
}
|
||||||
// The event must not share the to-do's UID, or clients treat them as one item.
|
// The event must not share the to-do's UID, or clients treat them as one item.
|
||||||
if !strings.Contains(out, "UID:t1-event@todo.example.dk") {
|
if !strings.Contains(out, "UID:t1-event@todo.example.dk") {
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Snooze: put a task out of the way without finishing or filing it. A snoozed task
|
||||||
|
-- sinks to the bottom of its list and greys out until the time it is snoozed to,
|
||||||
|
-- then comes back on its own — no job wakes it, because "snoozed" is simply
|
||||||
|
-- snoozed_until being still in the future, read against the clock each time.
|
||||||
|
--
|
||||||
|
-- Null means not snoozed, which is every task until somebody snoozes one.
|
||||||
|
ALTER TABLE tasks ADD COLUMN snoozed_until INTEGER;
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeName(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"andreas": "Andreas",
|
||||||
|
"Andreas": "Andreas",
|
||||||
|
"anne mette": "Anne Mette",
|
||||||
|
" kristian ": "Kristian",
|
||||||
|
"anne mette": "Anne Mette",
|
||||||
|
"McDonald": "McDonald", // an existing inner capital is kept
|
||||||
|
"óscar": "Óscar", // Unicode initial
|
||||||
|
"æblegaard": "Æblegaard",
|
||||||
|
"": "",
|
||||||
|
" ": "",
|
||||||
|
"mette-marie sø": "Mette-marie Sø", // only word-initials are raised
|
||||||
|
"iPhone listen": "IPhone Listen", // deliberately: the first letter is raised
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := NormalizeName(in); got != want {
|
||||||
|
t.Errorf("NormalizeName(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateUserCapitalisesName(t *testing.T) {
|
||||||
|
db := openTest(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
u := &User{Email: "andreas@example.dk", Name: "andreas", PasswordHash: "x"}
|
||||||
|
if err := db.CreateUser(ctx, u, "Inbox"); err != nil {
|
||||||
|
t.Fatalf("create user: %v", err)
|
||||||
|
}
|
||||||
|
got, err := db.UserByEmail(ctx, "andreas@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read back: %v", err)
|
||||||
|
}
|
||||||
|
if got.Name != "Andreas" {
|
||||||
|
t.Errorf("stored name = %q, want %q", got.Name, "Andreas")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeUserNamesBackfill(t *testing.T) {
|
||||||
|
db := openTest(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// An account as it looked before the app capitalised names: made properly,
|
||||||
|
// then set back to lower case behind the store's back to stand in for an old row.
|
||||||
|
u := &User{Email: "a@example.dk", Name: "Andreas", PasswordHash: "x"}
|
||||||
|
if err := db.CreateUser(ctx, u, "Inbox"); err != nil {
|
||||||
|
t.Fatalf("create user: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(ctx, `UPDATE users SET name = 'andreas' WHERE id = ?`, u.ID); err != nil {
|
||||||
|
t.Fatalf("downgrade name: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fixed, err := db.NormalizeUserNames(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize: %v", err)
|
||||||
|
}
|
||||||
|
if fixed != 1 {
|
||||||
|
t.Fatalf("fixed = %d, want 1", fixed)
|
||||||
|
}
|
||||||
|
got, _ := db.UserByID(ctx, u.ID)
|
||||||
|
if got.Name != "Andreas" {
|
||||||
|
t.Errorf("name after backfill = %q, want %q", got.Name, "Andreas")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotent: nothing left to do on a second pass.
|
||||||
|
again, err := db.NormalizeUserNames(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize again: %v", err)
|
||||||
|
}
|
||||||
|
if again != 0 {
|
||||||
|
t.Errorf("second pass fixed = %d, want 0", again)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -204,6 +204,17 @@ func (db *DB) dropPushEndpoint(ctx context.Context, endpoint string) error {
|
|||||||
// has failed enough times. A push service says 404 or 410 when an endpoint is gone,
|
// has failed enough times. A push service says 404 or 410 when an endpoint is gone,
|
||||||
// but it also has bad days — forgiving a few failures keeps a flaky network from
|
// but it also has bad days — forgiving a few failures keeps a flaky network from
|
||||||
// silently unsubscribing somebody's phone.
|
// silently unsubscribing somebody's phone.
|
||||||
|
// MarkPushDelivered records that a push went through: the failure count goes back
|
||||||
|
// to zero and last_used_at moves forward. Without it a subscription that failed a
|
||||||
|
// few times and then recovered would keep those failures on its record and be one
|
||||||
|
// flaky night away from the ten that delete it — even though it is working.
|
||||||
|
func (db *DB) MarkPushDelivered(ctx context.Context, endpoint string) error {
|
||||||
|
_, err := db.ExecContext(ctx,
|
||||||
|
`UPDATE push_subscriptions SET failures = 0, last_used_at = ? WHERE endpoint = ?`,
|
||||||
|
time.Now().Unix(), endpoint)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) RecordPushFailure(ctx context.Context, endpoint string, permanent bool) error {
|
func (db *DB) RecordPushFailure(ctx context.Context, endpoint string, permanent bool) error {
|
||||||
if permanent {
|
if permanent {
|
||||||
// Not scoped by user, and that is right here: this is the push service
|
// Not scoped by user, and that is right here: this is the push service
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ type Task struct {
|
|||||||
AssigneeID string
|
AssigneeID string
|
||||||
CompletedAt *time.Time
|
CompletedAt *time.Time
|
||||||
CompletedBy string
|
CompletedBy string
|
||||||
|
// SnoozedUntil, when set and still in the future, parks the task at the bottom
|
||||||
|
// of its list, greyed, until it passes. Nil means the task is not snoozed.
|
||||||
|
SnoozedUntil *time.Time
|
||||||
// CreatedBy is empty when the author's account has been deleted: `created_by`
|
// CreatedBy is empty when the author's account has been deleted: `created_by`
|
||||||
// is ON DELETE SET NULL so the task survives the person who wrote it.
|
// is ON DELETE SET NULL so the task survives the person who wrote it.
|
||||||
CreatedBy string
|
CreatedBy string
|
||||||
@@ -219,7 +222,7 @@ func (db *DB) ListTasks(ctx context.Context, userID string, f TaskFilter) ([]Tas
|
|||||||
SELECT t.id, t.project_id, t.section_id, t.parent_id, t.content, t.description,
|
SELECT t.id, t.project_id, t.section_id, t.parent_id, t.content, t.description,
|
||||||
t.priority, t.due_date, t.due_datetime, t.due_timezone, t.duration_min,
|
t.priority, t.due_date, t.due_datetime, t.due_timezone, t.duration_min,
|
||||||
t.recurrence_rule, t.assignee_id, t.completed_at, t.completed_by,
|
t.recurrence_rule, t.assignee_id, t.completed_at, t.completed_by,
|
||||||
t.created_by, t.sort_order, t.created_at, t.updated_at,` + taskCounts + `
|
t.created_by, t.sort_order, t.created_at, t.updated_at, t.snoozed_until,` + taskCounts + `
|
||||||
FROM tasks t
|
FROM tasks t
|
||||||
JOIN projects p ON p.id = t.project_id
|
JOIN projects p ON p.id = t.project_id
|
||||||
WHERE ` + strings.Join(where, " AND ") + `
|
WHERE ` + strings.Join(where, " AND ") + `
|
||||||
@@ -313,7 +316,7 @@ func (db *DB) GetTask(ctx context.Context, taskID, userID string) (*Task, error)
|
|||||||
SELECT t.id, t.project_id, t.section_id, t.parent_id, t.content, t.description,
|
SELECT t.id, t.project_id, t.section_id, t.parent_id, t.content, t.description,
|
||||||
t.priority, t.due_date, t.due_datetime, t.due_timezone, t.duration_min,
|
t.priority, t.due_date, t.due_datetime, t.due_timezone, t.duration_min,
|
||||||
t.recurrence_rule, t.assignee_id, t.completed_at, t.completed_by,
|
t.recurrence_rule, t.assignee_id, t.completed_at, t.completed_by,
|
||||||
t.created_by, t.sort_order, t.created_at, t.updated_at,`+taskCounts+`
|
t.created_by, t.sort_order, t.created_at, t.updated_at, t.snoozed_until,`+taskCounts+`
|
||||||
FROM tasks t WHERE t.id = ? AND t.deleted_at IS NULL`, taskID)
|
FROM tasks t WHERE t.id = ? AND t.deleted_at IS NULL`, taskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -390,7 +393,32 @@ func order(f TaskFilter) string {
|
|||||||
if f.CompletedOnly {
|
if f.CompletedOnly {
|
||||||
return "t.completed_at DESC, t.id DESC"
|
return "t.completed_at DESC, t.id DESC"
|
||||||
}
|
}
|
||||||
return "t.due_date IS NULL, t.due_date, t.priority, t.sort_order, t.created_at"
|
// A snoozed task sinks below everything else — the CASE is 1 while it is still
|
||||||
|
// snoozed and 0 the moment its time passes, so it rejoins the list in its proper
|
||||||
|
// place by the clock, with nothing to run. Compared as an integer, or the TEXT
|
||||||
|
// strftime returns would sort under every number.
|
||||||
|
snoozed := "(CASE WHEN t.snoozed_until > CAST(strftime('%s','now') AS INTEGER) THEN 1 ELSE 0 END)"
|
||||||
|
return snoozed + ", t.due_date IS NULL, t.due_date, t.priority, t.sort_order, t.created_at"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SnoozeTask parks a task until a moment, or wakes it when until is nil. Only the
|
||||||
|
// snooze changes; the due date, the time, everything else is left alone — a snooze
|
||||||
|
// is about when you want to see it, not when it is due.
|
||||||
|
func (db *DB) SnoozeTask(ctx context.Context, id string, until *time.Time) error {
|
||||||
|
var v any
|
||||||
|
if until != nil {
|
||||||
|
v = until.Unix()
|
||||||
|
}
|
||||||
|
res, err := db.ExecContext(ctx,
|
||||||
|
`UPDATE tasks SET snoozed_until = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`,
|
||||||
|
v, time.Now().Unix(), id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanTask(rows *sql.Rows) (Task, error) {
|
func scanTask(rows *sql.Rows) (Task, error) {
|
||||||
@@ -400,13 +428,13 @@ func scanTask(rows *sql.Rows) (Task, error) {
|
|||||||
// stays; it loses its author.
|
// stays; it loses its author.
|
||||||
var createdBy sql.NullString
|
var createdBy sql.NullString
|
||||||
var dueDatetime, completedAt sql.NullInt64
|
var dueDatetime, completedAt sql.NullInt64
|
||||||
var duration sql.NullInt64
|
var duration, snoozedUntil sql.NullInt64
|
||||||
var created, updated int64
|
var created, updated int64
|
||||||
|
|
||||||
err := rows.Scan(&t.ID, &t.ProjectID, §ionID, &parentID, &t.Content, &t.Description,
|
err := rows.Scan(&t.ID, &t.ProjectID, §ionID, &parentID, &t.Content, &t.Description,
|
||||||
&t.Priority, &dueDate, &dueDatetime, &dueTZ, &duration,
|
&t.Priority, &dueDate, &dueDatetime, &dueTZ, &duration,
|
||||||
&recurrence, &assignee, &completedAt, &completedBy,
|
&recurrence, &assignee, &completedAt, &completedBy,
|
||||||
&createdBy, &t.SortOrder, &created, &updated,
|
&createdBy, &t.SortOrder, &created, &updated, &snoozedUntil,
|
||||||
&t.SubtaskCount, &t.SubtaskDone, &t.AttachmentCount)
|
&t.SubtaskCount, &t.SubtaskDone, &t.AttachmentCount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return t, err
|
return t, err
|
||||||
@@ -432,6 +460,10 @@ func scanTask(rows *sql.Rows) (Task, error) {
|
|||||||
v := int(duration.Int64)
|
v := int(duration.Int64)
|
||||||
t.DurationMin = &v
|
t.DurationMin = &v
|
||||||
}
|
}
|
||||||
|
if snoozedUntil.Valid {
|
||||||
|
v := time.Unix(snoozedUntil.Int64, 0).UTC()
|
||||||
|
t.SnoozedUntil = &v
|
||||||
|
}
|
||||||
t.CreatedAt = time.Unix(created, 0).UTC()
|
t.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
t.UpdatedAt = time.Unix(updated, 0).UTC()
|
t.UpdatedAt = time.Unix(updated, 0).UTC()
|
||||||
return t, nil
|
return t, nil
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"github.com/kristianwind/verdande/internal/auth"
|
"github.com/kristianwind/verdande/internal/auth"
|
||||||
)
|
)
|
||||||
@@ -62,6 +63,26 @@ func NormalizeEmail(email string) string {
|
|||||||
return strings.ToLower(strings.TrimSpace(email))
|
return strings.ToLower(strings.TrimSpace(email))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NormalizeName trims a display name and capitalises the first letter of every
|
||||||
|
// word, leaving the rest of each word exactly as typed. So "andreas" becomes
|
||||||
|
// "Andreas" and "anne mette" becomes "Anne Mette", while a name that already
|
||||||
|
// carries a capital in the middle — "McDonald", an "iPhone" — keeps it: only the
|
||||||
|
// first letter of a word is ever raised, never lowered.
|
||||||
|
//
|
||||||
|
// A name is the person's to spell; the one thing the instance insists on is that
|
||||||
|
// it does not start in lower case, because a column of lower-case first names
|
||||||
|
// reads as a bug the person cannot fix from their own keyboard. Unicode-aware, so
|
||||||
|
// "æblegaard" and "óscar" are raised the same as an ASCII initial.
|
||||||
|
func NormalizeName(name string) string {
|
||||||
|
fields := strings.Fields(name)
|
||||||
|
for i, f := range fields {
|
||||||
|
r := []rune(f)
|
||||||
|
r[0] = unicode.ToUpper(r[0])
|
||||||
|
fields[i] = string(r)
|
||||||
|
}
|
||||||
|
return strings.Join(fields, " ")
|
||||||
|
}
|
||||||
|
|
||||||
// CreateUser inserts a user and gives them an Inbox, in one transaction.
|
// CreateUser inserts a user and gives them an Inbox, in one transaction.
|
||||||
//
|
//
|
||||||
// The Inbox is created here rather than lazily on first use because every other
|
// The Inbox is created here rather than lazily on first use because every other
|
||||||
@@ -70,6 +91,7 @@ func NormalizeEmail(email string) string {
|
|||||||
// of them end up racing to create the second one.
|
// of them end up racing to create the second one.
|
||||||
func (db *DB) CreateUser(ctx context.Context, u *User, inboxName string) error {
|
func (db *DB) CreateUser(ctx context.Context, u *User, inboxName string) error {
|
||||||
u.Email = NormalizeEmail(u.Email)
|
u.Email = NormalizeEmail(u.Email)
|
||||||
|
u.Name = NormalizeName(u.Name)
|
||||||
if u.ID == "" {
|
if u.ID == "" {
|
||||||
u.ID = NewID()
|
u.ID = NewID()
|
||||||
}
|
}
|
||||||
@@ -473,6 +495,7 @@ func (db *DB) UpdatePasswordHash(ctx context.Context, userID, hash, keepSessionI
|
|||||||
// invites were sent to, and changing it is a re-verification flow rather than a
|
// invites were sent to, and changing it is a re-verification flow rather than a
|
||||||
// field. Neither is is_admin — an account cannot promote itself.
|
// field. Neither is is_admin — an account cannot promote itself.
|
||||||
func (db *DB) UpdateProfile(ctx context.Context, userID, name, timezone, locale string) error {
|
func (db *DB) UpdateProfile(ctx context.Context, userID, name, timezone, locale string) error {
|
||||||
|
name = NormalizeName(name)
|
||||||
res, err := db.ExecContext(ctx,
|
res, err := db.ExecContext(ctx,
|
||||||
`UPDATE users SET name = ?, timezone = ?, locale = ?, updated_at = ? WHERE id = ?`,
|
`UPDATE users SET name = ?, timezone = ?, locale = ?, updated_at = ? WHERE id = ?`,
|
||||||
name, timezone, locale, time.Now().Unix(), userID)
|
name, timezone, locale, time.Now().Unix(), userID)
|
||||||
@@ -485,6 +508,56 @@ func (db *DB) UpdateProfile(ctx context.Context, userID, name, timezone, locale
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NormalizeUserNames capitalises the first letter of every existing user's name,
|
||||||
|
// for the accounts created before the app started doing it on the way in.
|
||||||
|
//
|
||||||
|
// Idempotent: a name already normalised is left untouched, so it costs one pass
|
||||||
|
// of names at each start and writes nothing once it has caught up. Like SealNotes
|
||||||
|
// it runs at boot rather than as a migration, because the rule lives in Go —
|
||||||
|
// Unicode-aware and per word — and SQLite's own UPPER cannot spell it.
|
||||||
|
//
|
||||||
|
// `updated_at` is not touched: fixing the spelling of a name the person chose is
|
||||||
|
// the instance tidying up after itself, not the person editing their profile.
|
||||||
|
func (db *DB) NormalizeUserNames(ctx context.Context) (int, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `SELECT id, name FROM users`)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
type pending struct{ id, name string }
|
||||||
|
var todo []pending
|
||||||
|
for rows.Next() {
|
||||||
|
var id, name string
|
||||||
|
if err := rows.Scan(&id, &name); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if n := NormalizeName(name); n != name {
|
||||||
|
todo = append(todo, pending{id, n})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if len(todo) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
|
for _, p := range todo {
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE users SET name = ? WHERE id = ?`, p.name, p.id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return len(todo), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) SetTOTPSecret(ctx context.Context, userID, secret string, enabled bool) error {
|
func (db *DB) SetTOTPSecret(ctx context.Context, userID, secret string, enabled bool) error {
|
||||||
// Sealed, like a mailbox password and for the same reason: the copies, not the
|
// Sealed, like a mailbox password and for the same reason: the copies, not the
|
||||||
// server. A TOTP secret in a downloadable backup lets whoever holds the copy
|
// server. A TOTP secret in a downloadable backup lets whoever holds the copy
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ extra_css:
|
|||||||
nav:
|
nav:
|
||||||
- Home: index.md
|
- Home: index.md
|
||||||
- Setting it up:
|
- Setting it up:
|
||||||
- Self-hosting: self-hosting.md
|
|
||||||
- As a Rune: rune.md
|
- As a Rune: rune.md
|
||||||
|
- With Docker: self-hosting.md
|
||||||
- Configuration: configuration.md
|
- Configuration: configuration.md
|
||||||
- Using it:
|
- Using it:
|
||||||
- Signing in: signing-in.md
|
- Signing in: signing-in.md
|
||||||
|
|||||||
@@ -856,8 +856,11 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a class="button primary" href="https://kristianwind.github.io/verdande/self-hosting/">
|
<a class="button primary" href="https://kristianwind.github.io/verdande/rune/">
|
||||||
Self-host it
|
As a Rune
|
||||||
|
</a>
|
||||||
|
<a class="button" href="https://kristianwind.github.io/verdande/self-hosting/">
|
||||||
|
With Docker
|
||||||
</a>
|
</a>
|
||||||
<a class="button" href="https://github.com/kristianwind/verdande">Source</a>
|
<a class="button" href="https://github.com/kristianwind/verdande">Source</a>
|
||||||
<a class="button" href="https://kristianwind.github.io/verdande/">Documentation</a>
|
<a class="button" href="https://kristianwind.github.io/verdande/">Documentation</a>
|
||||||
@@ -949,12 +952,12 @@
|
|||||||
<figure class="shot">
|
<figure class="shot">
|
||||||
<img
|
<img
|
||||||
src="screenshots/board.webp"
|
src="screenshots/board.webp"
|
||||||
alt="A project in board view: one column per section, with task cards showing due dates, labels and repetition."
|
alt="A project in board view: one column per section, with task cards showing due dates, labels, an arrow to whoever a task is delegated to, and a snoozed card greyed at the foot of its column."
|
||||||
width="1400"
|
width="1400"
|
||||||
height="900"
|
height="900"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<figcaption>Board view — one column per section.</figcaption>
|
<figcaption>Board view — one column per section. A delegated card carries an arrow to whoever has it; a snoozed one greys at the foot.</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
<figure class="shot">
|
<figure class="shot">
|
||||||
<img
|
<img
|
||||||
@@ -1009,12 +1012,12 @@
|
|||||||
<figure class="shot">
|
<figure class="shot">
|
||||||
<img
|
<img
|
||||||
src="screenshots/delegated.webp"
|
src="screenshots/delegated.webp"
|
||||||
alt="Waiting on others: tasks that have been handed to somebody else, grouped under the person who has them."
|
alt="Waiting on others: tasks that have been handed to somebody else, grouped under the person who has them, each row carrying an arrow to that person."
|
||||||
width="1400"
|
width="1400"
|
||||||
height="900"
|
height="900"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<figcaption>Waiting on others — grouped by who has it.</figcaption>
|
<figcaption>Waiting on others — grouped by who has it, each marked with an arrow to them.</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
<figure class="shot">
|
<figure class="shot">
|
||||||
<img
|
<img
|
||||||
@@ -1070,8 +1073,8 @@
|
|||||||
<h3>Repeats, and says so in words</h3>
|
<h3>Repeats, and says so in words</h3>
|
||||||
<p>
|
<p>
|
||||||
Recurring tasks are RRULE underneath and "every other Tuesday" on the
|
Recurring tasks are RRULE underneath and "every other Tuesday" on the
|
||||||
screen. Reminders are absolute or relative, and go out to the devices
|
screen. Reminders are absolute or relative, and go out as Web Push to
|
||||||
you have allowed them on.
|
an iPhone or a Mac you have allowed them on.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
@@ -1104,8 +1107,17 @@
|
|||||||
<h3>Waiting on others</h3>
|
<h3>Waiting on others</h3>
|
||||||
<p>
|
<p>
|
||||||
What you have handed to somebody else, on its own page, grouped under
|
What you have handed to somebody else, on its own page, grouped under
|
||||||
the person who has it. The thing a shared list stops being able to tell
|
the person who has it — and marked where you meet it too, with an arrow
|
||||||
you the moment it is shared.
|
out of your hands, so a list reads at a glance as which tasks are yours
|
||||||
|
and which you are waiting on.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<h3>Snooze it out of the way</h3>
|
||||||
|
<p>
|
||||||
|
Park a task to this evening, tomorrow, next week, or a moment you pick.
|
||||||
|
It greys and sinks to the bottom of the list until then, and comes back
|
||||||
|
by itself — without touching when it is actually due.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1225,6 +1237,23 @@
|
|||||||
<a href="https://github.com/kristianwind/verdande/releases">the releases page</a>.
|
<a href="https://github.com/kristianwind/verdande/releases">the releases page</a>.
|
||||||
</p>
|
</p>
|
||||||
<ul class="lately">
|
<ul class="lately">
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.41</span>
|
||||||
|
A task you have delegated is marked where you meet it, not only on the
|
||||||
|
"Waiting on others" page. A task can be snoozed to the bottom of the list
|
||||||
|
until a moment you choose, and comes back by itself.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.41</span>
|
||||||
|
Web Push reminders reach an iPhone and a Mac again, and every dated task
|
||||||
|
now shows up in a calendar you have subscribed to — not only the timed ones.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="ver">0.41</span>
|
||||||
|
The calendar draws each task to its length: drag the foot to resize, or the
|
||||||
|
body to move it. A single note can be shared with one person, and a
|
||||||
|
<code>[[note]]</code> is a link you follow inline.
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span class="ver">0.34</span>
|
<span class="ver">0.34</span>
|
||||||
Sections can be dragged into the order the work actually happens in. The
|
Sections can be dragged into the order the work actually happens in. The
|
||||||
@@ -1236,21 +1265,6 @@
|
|||||||
or drag in, a source view, and a copy button on code blocks. Search
|
or drag in, a source view, and a copy button on code blocks. Search
|
||||||
stopped answering by date and started answering by relevance.
|
stopped answering by date and started answering by relevance.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
<span class="ver">0.32</span>
|
|
||||||
A calendar of its own as a menu item, with a read-only Google calendar
|
|
||||||
that can be laid over it.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span class="ver">0.31</span>
|
|
||||||
Mailboxes over IMAP, so flagging a mail in iCloud or Fastmail makes a
|
|
||||||
task — not only Gmail.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span class="ver">0.30</span>
|
|
||||||
An installation count that says exactly what it sends, and can be turned
|
|
||||||
off in one click.
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 40 KiB |
@@ -86,6 +86,29 @@ test('hurtig tilføjelse opretter en opgave, og den kan lukkes', async ({ page }
|
|||||||
expect(trouble).toEqual([]);
|
expect(trouble).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('en URL i en opgave bliver et klikbart link, og resten åbner opgaven', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
const box = page.getByLabel('Ny opgave');
|
||||||
|
// The URL is one token, so the parser leaves it in the title; "i dag" is what it
|
||||||
|
// takes. The link is made at display time — the stored title is still plain text.
|
||||||
|
await box.fill('læs https://example.com/artikel i dag');
|
||||||
|
await box.press('Enter');
|
||||||
|
|
||||||
|
const row = page.locator('.row').filter({ hasText: 'læs' });
|
||||||
|
const link = row.getByRole('link', { name: 'https://example.com/artikel' });
|
||||||
|
await expect(link).toHaveAttribute('href', 'https://example.com/artikel');
|
||||||
|
await expect(link).toHaveAttribute('rel', /noopener/);
|
||||||
|
|
||||||
|
// Clicking the plain part of the title still opens the task; only the link is
|
||||||
|
// carved out. Aimed at the top-left, over "læs", not the URL in the middle.
|
||||||
|
await row.locator('.content').click({ position: { x: 3, y: 3 } });
|
||||||
|
await expect(page.getByRole('complementary', { name: 'Opgave' })).toBeVisible();
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test('hvert link i sidebjælken fører et sted hen', async ({ page }) => {
|
test('hvert link i sidebjælken fører et sted hen', async ({ page }) => {
|
||||||
const trouble = watchForTrouble(page);
|
const trouble = watchForTrouble(page);
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
@@ -127,6 +150,32 @@ test('hvert link i sidebjælken fører et sted hen', async ({ page }) => {
|
|||||||
expect(trouble).toEqual([]);
|
expect(trouble).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('en opgave kan slumres og hentes frem igen', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// "i dag" is parsed as the due date, so the task is named "Alfa" and shows here.
|
||||||
|
const box = page.getByLabel('Ny opgave');
|
||||||
|
await box.fill('Alfa i dag');
|
||||||
|
await box.press('Enter');
|
||||||
|
|
||||||
|
// Snooze it to tomorrow from its drawer.
|
||||||
|
await page.getByText('Alfa', { exact: true }).click();
|
||||||
|
const drawer = page.getByRole('complementary', { name: 'Opgave' });
|
||||||
|
await drawer.getByRole('button', { name: 'I morgen' }).click();
|
||||||
|
|
||||||
|
// The row greys — it carries the snoozed class and a "slumret til" mark.
|
||||||
|
const alfa = page.locator('.row', { hasText: 'Alfa' });
|
||||||
|
await expect(alfa).toHaveClass(/snoozed/);
|
||||||
|
await expect(alfa.locator('.snooze-mark')).toBeVisible();
|
||||||
|
|
||||||
|
// The drawer now offers to wake it, and waking takes the grey off.
|
||||||
|
await drawer.getByRole('button', { name: 'Væk nu' }).click();
|
||||||
|
await expect(alfa).not.toHaveClass(/snoozed/);
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test('en opgave kan åbnes og redigeres', async ({ page }) => {
|
test('en opgave kan åbnes og redigeres', async ({ page }) => {
|
||||||
const trouble = watchForTrouble(page);
|
const trouble = watchForTrouble(page);
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
@@ -196,6 +245,38 @@ test('hver fane under indstillinger renderer', async ({ page }) => {
|
|||||||
expect(trouble).toEqual([]);
|
expect(trouble).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('menu- og brødtekststørrelse sættes hver for sig og overlever en genindlæsning', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/indstillinger');
|
||||||
|
|
||||||
|
// Two independent knobs under Udseende — the sidebar's size, and the body text's.
|
||||||
|
await page.getByLabel('Menu', { exact: true }).selectOption('large');
|
||||||
|
await page.getByLabel('Brødtekst', { exact: true }).selectOption('xl');
|
||||||
|
await expect(page.getByLabel('Menu', { exact: true })).toHaveValue('large');
|
||||||
|
|
||||||
|
const attrs = () =>
|
||||||
|
page.evaluate(() => [
|
||||||
|
document.documentElement.dataset.menuSize,
|
||||||
|
document.documentElement.dataset.textSize
|
||||||
|
]);
|
||||||
|
expect(await attrs()).toEqual(['large', 'xl']);
|
||||||
|
|
||||||
|
// They live in localStorage like the theme, so they survive a reload — and are
|
||||||
|
// applied before first paint, so the picker comes back showing the saved choice.
|
||||||
|
await page.reload();
|
||||||
|
expect(await attrs()).toEqual(['large', 'xl']);
|
||||||
|
await expect(page.getByLabel('Menu', { exact: true })).toHaveValue('large');
|
||||||
|
|
||||||
|
// Put them back, so the shared account does not leak a size into other tests.
|
||||||
|
await page.getByLabel('Menu', { exact: true }).selectOption('default');
|
||||||
|
await page.getByLabel('Brødtekst', { exact: true }).selectOption('default');
|
||||||
|
expect(await attrs()).toEqual([undefined, undefined]);
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The PWA's own files. Both of the bugs found last in this project were of this
|
* The PWA's own files. Both of the bugs found last in this project were of this
|
||||||
* kind — a manifest promising icons that were never generated — and they are
|
* kind — a manifest promising icons that were never generated — and they are
|
||||||
@@ -404,10 +485,10 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
|||||||
.evaluate((el) => el.getBoundingClientRect().left + parseFloat(getComputedStyle(el).paddingLeft));
|
.evaluate((el) => el.getBoundingClientRect().left + parseFloat(getComputedStyle(el).paddingLeft));
|
||||||
expect(headLeft, 'gruppehovedet er rykket ind').toBeLessThanOrEqual(looseLeft);
|
expect(headLeft, 'gruppehovedet er rykket ind').toBeLessThanOrEqual(looseLeft);
|
||||||
|
|
||||||
// A project inside a group starts further in than a row outside one. Measured
|
// A project sits at the same left margin whether or not it is in a group — one
|
||||||
// rather than asserted on a class, because indentation is a fact about where
|
// straight edge down the sidebar, with the bold heading doing the grouping the
|
||||||
// the row's contents land — and measured at the *content* edge, not the box:
|
// indent used to. Measured at the *content* edge, not the box, because what the
|
||||||
// the indent is padding, so the element's own left edge does not move.
|
// eye follows is where the text lands.
|
||||||
await sidebar.getByRole('link', { name: 'Regnskab' }).dragTo(heading);
|
await sidebar.getByRole('link', { name: 'Regnskab' }).dragTo(heading);
|
||||||
const contentLeft = (el) =>
|
const contentLeft = (el) =>
|
||||||
el.getBoundingClientRect().left + parseFloat(getComputedStyle(el).paddingLeft);
|
el.getBoundingClientRect().left + parseFloat(getComputedStyle(el).paddingLeft);
|
||||||
@@ -415,7 +496,10 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
|||||||
sidebar.locator('.folder > a').first().evaluate(contentLeft),
|
sidebar.locator('.folder > a').first().evaluate(contentLeft),
|
||||||
sidebar.locator('.views a').first().evaluate(contentLeft)
|
sidebar.locator('.views a').first().evaluate(contentLeft)
|
||||||
]);
|
]);
|
||||||
expect(inside, 'et projekt i en gruppe er ikke rykket ind').toBeGreaterThan(outside);
|
expect(
|
||||||
|
Math.abs(inside - outside),
|
||||||
|
'et projekt i en gruppe står ikke i flugt med de faste visninger'
|
||||||
|
).toBeLessThan(2);
|
||||||
|
|
||||||
await sidebar
|
await sidebar
|
||||||
.getByRole('link', { name: 'Regnskab' })
|
.getByRole('link', { name: 'Regnskab' })
|
||||||
@@ -876,6 +960,8 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
|
|||||||
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||||
const andreas = await context.newPage();
|
const andreas = await context.newPage();
|
||||||
await andreas.goto(link);
|
await andreas.goto(link);
|
||||||
|
// Typed in lower case on purpose: the instance capitalises a name on the way
|
||||||
|
// in, so from here on this person is "Andreas" everywhere they are shown.
|
||||||
await andreas.getByLabel('Navn', { exact: true }).fill('andreas');
|
await andreas.getByLabel('Navn', { exact: true }).fill('andreas');
|
||||||
await andreas.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
await andreas.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
||||||
await andreas.getByRole('button', { name: 'Opret konto' }).click();
|
await andreas.getByRole('button', { name: 'Opret konto' }).click();
|
||||||
@@ -894,7 +980,7 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
|
|||||||
await page.reload();
|
await page.reload();
|
||||||
await projectAction(page, /Delt/);
|
await projectAction(page, /Delt/);
|
||||||
await expect(page.getByText('Ejer')).toBeVisible();
|
await expect(page.getByText('Ejer')).toBeVisible();
|
||||||
await page.getByLabel('Rolle for andreas').selectOption('editor');
|
await page.getByLabel('Rolle for Andreas').selectOption('editor');
|
||||||
|
|
||||||
await andreas.reload();
|
await andreas.reload();
|
||||||
await expect(andreas.getByLabel('Ny opgave')).toBeVisible();
|
await expect(andreas.getByLabel('Ny opgave')).toBeVisible();
|
||||||
@@ -907,11 +993,11 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
|
|||||||
await page.getByLabel('Ny opgave').press('Enter');
|
await page.getByLabel('Ny opgave').press('Enter');
|
||||||
await page.getByText('brænde kaffe', { exact: true }).click();
|
await page.getByText('brænde kaffe', { exact: true }).click();
|
||||||
const drawer = page.getByRole('complementary', { name: 'Opgave' });
|
const drawer = page.getByRole('complementary', { name: 'Opgave' });
|
||||||
await drawer.getByLabel('Ansvarlig').selectOption({ label: 'andreas' });
|
await drawer.getByLabel('Ansvarlig').selectOption({ label: 'Andreas' });
|
||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
const row = page.locator('.row').filter({ hasText: 'brænde kaffe' });
|
const row = page.locator('.row').filter({ hasText: 'brænde kaffe' });
|
||||||
await expect(row.getByTitle('Ansvarlig: andreas')).toBeVisible();
|
await expect(row.getByTitle('Uddelegeret til Andreas')).toBeVisible();
|
||||||
// And not on a task that is nobody's.
|
// And not on a task that is nobody's.
|
||||||
await page.getByLabel('Ny opgave').fill('min egen');
|
await page.getByLabel('Ny opgave').fill('min egen');
|
||||||
await page.getByLabel('Ny opgave').press('Enter');
|
await page.getByLabel('Ny opgave').press('Enter');
|
||||||
@@ -1439,9 +1525,18 @@ test('en gruppe er en side med sine projekter, en beskrivelse og filer', async (
|
|||||||
// Into the group, then onto its page — which is what the heading is a link to
|
// Into the group, then onto its page — which is what the heading is a link to
|
||||||
// now. The chevron beside it still folds; they are two intentions and the old
|
// now. The chevron beside it still folds; they are two intentions and the old
|
||||||
// single target could only serve the smaller one.
|
// single target could only serve the smaller one.
|
||||||
await sidebar
|
//
|
||||||
.getByRole('link', { name: 'Drejebænk' })
|
// The drop is retried until the project actually nests under the group: a native
|
||||||
.dragTo(sidebar.getByRole('link', { name: 'Værksted' }));
|
// HTML5 drag occasionally does not register the first time under CI load, and a
|
||||||
|
// single dragTo then leaves the group empty and the page assertion failing for a
|
||||||
|
// reason that has nothing to do with what the test is about.
|
||||||
|
const nested = sidebar.locator('.folder > a').filter({ hasText: 'Drejebænk' });
|
||||||
|
await expect(async () => {
|
||||||
|
await sidebar
|
||||||
|
.getByRole('link', { name: 'Drejebænk' })
|
||||||
|
.dragTo(sidebar.getByRole('link', { name: 'Værksted' }));
|
||||||
|
await expect(nested).toHaveCount(1, { timeout: 1000 });
|
||||||
|
}).toPass({ timeout: 10000 });
|
||||||
await sidebar.getByRole('link', { name: 'Værksted' }).click();
|
await sidebar.getByRole('link', { name: 'Værksted' }).click();
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Værksted', level: 1 })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Værksted', level: 1 })).toBeVisible();
|
||||||
|
|||||||
@@ -483,6 +483,17 @@ async function seed({ colleague, noteTitle }) {
|
|||||||
const made = {};
|
const made = {};
|
||||||
for (const task of tasks) made[task.content] = await api('/tasks', task);
|
for (const task of tasks) made[task.content] = await api('/tasks', task);
|
||||||
|
|
||||||
|
// One task snoozed, so the board and the list show a card parked at the bottom
|
||||||
|
// of its section — greyed, with a "snoozed until" mark — set aside rather than
|
||||||
|
// forgotten, and without touching when it is due.
|
||||||
|
const snoozeUntil = (() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() + 2);
|
||||||
|
d.setHours(9, 0, 0, 0);
|
||||||
|
return d.toISOString();
|
||||||
|
})();
|
||||||
|
await api(`/tasks/${made['Write the job advert'].id}/snooze`, { until: snoozeUntil });
|
||||||
|
|
||||||
// A saved filter, because the sidebar shows them and an empty heading reads as
|
// A saved filter, because the sidebar shows them and an empty heading reads as
|
||||||
// a feature that does not work.
|
// a feature that does not work.
|
||||||
await api('/filters', { name: 'Important this week', query: 'p1 & 7 days' });
|
await api('/filters', { name: 'Important this week', query: 'p1 & 7 days' });
|
||||||
|
|||||||
@@ -86,6 +86,12 @@
|
|||||||
--text-xl: 1.375rem;
|
--text-xl: 1.375rem;
|
||||||
--text-2xl: 1.75rem;
|
--text-2xl: 1.75rem;
|
||||||
|
|
||||||
|
/* The sidebar's own text size, its own knob (Settings → Appearance) so the menu
|
||||||
|
can be made easier to read without dragging the whole list with it. Default is
|
||||||
|
a step up from the old --text-sm the nav used, at medium weight, so the menu
|
||||||
|
items read as menu items. [data-menu-size] on <html> overrides it. */
|
||||||
|
--menu-size: 0.9375rem;
|
||||||
|
|
||||||
/* Spacing on a 4px grid. */
|
/* Spacing on a 4px grid. */
|
||||||
--s1: 0.25rem;
|
--s1: 0.25rem;
|
||||||
--s2: 0.5rem;
|
--s2: 0.5rem;
|
||||||
@@ -516,3 +522,41 @@ button {
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Body text size — its own control in Settings → Appearance, for reading the list
|
||||||
|
and the notes larger (or smaller) without touching the menu. It re-scales the
|
||||||
|
shared type tokens the same way a look does; placed after the looks so that an
|
||||||
|
explicit size choice wins the tokens they both set. The menu has its own knob
|
||||||
|
below and does not move with this one. */
|
||||||
|
[data-text-size='small'] {
|
||||||
|
--text-base: 0.875rem;
|
||||||
|
--text-sm: 0.75rem;
|
||||||
|
--text-lg: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-text-size='large'] {
|
||||||
|
--text-base: 1.0625rem;
|
||||||
|
--text-sm: 0.9375rem;
|
||||||
|
--text-lg: 1.1875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-text-size='xl'] {
|
||||||
|
--text-base: 1.1875rem;
|
||||||
|
--text-sm: 1.0625rem;
|
||||||
|
--text-lg: 1.3125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Menu (sidebar) font size — independent of the body size above, because the two
|
||||||
|
are separate questions: a menu you can read at a glance, and a list you can read
|
||||||
|
a paragraph of. */
|
||||||
|
[data-menu-size='small'] {
|
||||||
|
--menu-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-menu-size='large'] {
|
||||||
|
--menu-size: 1.0625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-menu-size='xl'] {
|
||||||
|
--menu-size: 1.1875rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,14 @@
|
|||||||
// face and again in another, which is a visible jump.
|
// face and again in another, which is a visible jump.
|
||||||
const look = localStorage.getItem('verdande:look');
|
const look = localStorage.getItem('verdande:look');
|
||||||
if (look && look !== 'verdande') document.documentElement.dataset.look = look;
|
if (look && look !== 'verdande') document.documentElement.dataset.look = look;
|
||||||
|
|
||||||
|
// The font-size choices change layout too, so they belong before first
|
||||||
|
// paint for the same reason: otherwise the sidebar and the list are drawn
|
||||||
|
// once at one size and again at another.
|
||||||
|
const menuSize = localStorage.getItem('verdande:menu-size');
|
||||||
|
if (menuSize && menuSize !== 'default') document.documentElement.dataset.menuSize = menuSize;
|
||||||
|
const textSize = localStorage.getItem('verdande:text-size');
|
||||||
|
if (textSize && textSize !== 'default') document.documentElement.dataset.textSize = textSize;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Private browsing can refuse localStorage; the dark default stands.
|
// Private browsing can refuse localStorage; the dark default stands.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -310,6 +310,7 @@ export const api = {
|
|||||||
completeTask: (id) => post(`/tasks/${id}/complete`),
|
completeTask: (id) => post(`/tasks/${id}/complete`),
|
||||||
reopenTask: (id) => post(`/tasks/${id}/reopen`),
|
reopenTask: (id) => post(`/tasks/${id}/reopen`),
|
||||||
moveTask: (id, data) => post(`/tasks/${id}/move`, data),
|
moveTask: (id, data) => post(`/tasks/${id}/move`, data),
|
||||||
|
snoozeTask: (id, until) => post(`/tasks/${id}/snooze`, { until: until ?? '' }),
|
||||||
|
|
||||||
quickAdd: (text, projectId, sectionId) =>
|
quickAdd: (text, projectId, sectionId) =>
|
||||||
post('/tasks/quick-add', { text, project_id: projectId, section_id: sectionId }),
|
post('/tasks/quick-add', { text, project_id: projectId, section_id: sectionId }),
|
||||||
|
|||||||
@@ -487,6 +487,7 @@
|
|||||||
it, which is the second time that drop has earned its test. -->
|
it, which is the second time that drop has earned its test. -->
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
|
data-view={item.key}
|
||||||
class:active={current === item.href}
|
class:active={current === item.href}
|
||||||
class:receiving={item.key === 'today' && overToday}
|
class:receiving={item.key === 'today' && overToday}
|
||||||
onclick={onnavigate}
|
onclick={onnavigate}
|
||||||
@@ -529,6 +530,52 @@
|
|||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
{:else if item.key === 'today'}
|
||||||
|
<!-- A star, the way Things marks Today: the one view you open first. -->
|
||||||
|
<svg class="mark" viewBox="0 0 16 16" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M8 2l1.7 3.6 3.9.5-2.9 2.7.7 3.9L8 10.9 4.6 13.4l.7-3.9L2.4 6.1l3.9-.5z"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.3"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{:else if item.key === 'upcoming'}
|
||||||
|
<!-- A calendar with a day marked — the Calendar view carries the plain
|
||||||
|
grid, so the marked day keeps the two apart. -->
|
||||||
|
<svg class="mark" viewBox="0 0 16 16" aria-hidden="true">
|
||||||
|
<rect
|
||||||
|
x="2.2"
|
||||||
|
y="3.2"
|
||||||
|
width="11.6"
|
||||||
|
height="10.6"
|
||||||
|
rx="1.4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M2.2 6.4h11.6M5.6 2.2v2M10.4 2.2v2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.4"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
<rect x="8.8" y="8.6" width="2.6" height="2.6" rx="0.5" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
{:else if item.key === 'delegated'}
|
||||||
|
<!-- A person: the tasks you are waiting on someone else for. -->
|
||||||
|
<svg class="mark" viewBox="0 0 16 16" aria-hidden="true">
|
||||||
|
<circle cx="8" cy="5.4" r="2.5" fill="none" stroke="currentColor" stroke-width="1.4" />
|
||||||
|
<path
|
||||||
|
d="M3.4 13.4c0-2.6 2.1-4.2 4.6-4.2s4.6 1.6 4.6 4.2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.4"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="dot" class:today={item.key === 'today'} aria-hidden="true"></span>
|
<span class="dot" class:today={item.key === 'today'} aria-hidden="true"></span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -570,6 +617,7 @@
|
|||||||
{#snippet projectRow(project, sortable)}
|
{#snippet projectRow(project, sortable)}
|
||||||
<a
|
<a
|
||||||
href="/projekt/{project.id}"
|
href="/projekt/{project.id}"
|
||||||
|
data-view={project.is_inbox ? 'inbox' : undefined}
|
||||||
class:sortable
|
class:sortable
|
||||||
class:active={current === `/projekt/${project.id}`}
|
class:active={current === `/projekt/${project.id}`}
|
||||||
class:dragging={draggingId === project.id}
|
class:dragging={draggingId === project.id}
|
||||||
@@ -593,7 +641,29 @@
|
|||||||
? onTaskDrop(e, project)
|
? onTaskDrop(e, project)
|
||||||
: sortable && onDrop(e, project)}
|
: sortable && onDrop(e, project)}
|
||||||
>
|
>
|
||||||
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"></span>
|
{#if project.is_inbox}
|
||||||
|
<!-- A tray, not a coloured dot: the Inbox is a fixed view that happens to
|
||||||
|
be a project, and it reads as one of the views above, not as the first
|
||||||
|
of the projects below. -->
|
||||||
|
<svg class="mark" viewBox="0 0 16 16" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M2.3 9.2L4 3.4a1.1 1.1 0 011-.8h6a1.1 1.1 0 011 .8l1.7 5.8"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.3"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M2.3 9.2v2.9A1.3 1.3 0 003.6 13.4h8.8a1.3 1.3 0 001.3-1.3V9.2h-3.3l-1 1.7H6.6l-1-1.7z"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.3"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"></span>
|
||||||
|
{/if}
|
||||||
{projectName(project)}
|
{projectName(project)}
|
||||||
<!-- Open tasks, not people. The same grey number meant three things in this
|
<!-- Open tasks, not people. The same grey number meant three things in this
|
||||||
one column — people at a project, projects at a group, tasks at a label
|
one column — people at a project, projects at a group, tasks at a label
|
||||||
@@ -656,7 +726,7 @@
|
|||||||
and with every project filed there would be no loose row left to aim at. -->
|
and with every project filed there would be no loose row left to aim at. -->
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="group-head"
|
class="group-head sticky-head"
|
||||||
class:over={overGroup === ''}
|
class:over={overGroup === ''}
|
||||||
ondragover={(e) => {
|
ondragover={(e) => {
|
||||||
if (!carries(e, PROJECT)) return;
|
if (!carries(e, PROJECT)) return;
|
||||||
@@ -1005,6 +1075,30 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--s5);
|
gap: var(--s5);
|
||||||
|
/* The sidebar is padded, so the scrollbar sat a pad-width inside the edge and
|
||||||
|
read as floating in the middle. Pull the scroller out to the border and pad
|
||||||
|
the content back, so the bar sits against the edge where a scrollbar
|
||||||
|
belongs — the rows do not move. Thin and dim, so it is present without
|
||||||
|
drawing the eye. */
|
||||||
|
margin-right: calc(var(--s3) * -1);
|
||||||
|
padding-right: var(--s3);
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--line-strong) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroller::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroller::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--line-strong);
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 3px solid transparent;
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroller::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Wider than it looks: a 1px target is a target you miss. The visible line stays
|
/* Wider than it looks: a 1px target is a target you miss. The visible line stays
|
||||||
@@ -1133,8 +1227,26 @@
|
|||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
a.active .mark {
|
/* The fixed views carry a colour of their own, the way Things does — the star
|
||||||
color: var(--accent);
|
amber, the Inbox blue — and keep it whether or not they are the active row.
|
||||||
|
So no active-state override here; the colour is the icon's identity. */
|
||||||
|
[data-view='inbox'] .mark {
|
||||||
|
color: var(--color-blue);
|
||||||
|
}
|
||||||
|
[data-view='today'] .mark {
|
||||||
|
color: var(--color-amber);
|
||||||
|
}
|
||||||
|
[data-view='upcoming'] .mark {
|
||||||
|
color: var(--color-tomato);
|
||||||
|
}
|
||||||
|
[data-view='delegated'] .mark {
|
||||||
|
color: var(--color-violet);
|
||||||
|
}
|
||||||
|
[data-view='notes'] .mark {
|
||||||
|
color: var(--color-teal);
|
||||||
|
}
|
||||||
|
[data-view='calendar'] .mark {
|
||||||
|
color: var(--color-indigo);
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-head {
|
.group-head {
|
||||||
@@ -1144,6 +1256,17 @@
|
|||||||
padding: 0 var(--s2) var(--s2);
|
padding: 0 var(--s2) var(--s2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The Projekter heading holds the only way to add a project or a group, so it
|
||||||
|
must not scroll away with a long list. Pinned to the top of the scroller, on
|
||||||
|
the sidebar's own ground so the rows pass under it. */
|
||||||
|
.sticky-head {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 3;
|
||||||
|
padding-top: var(--s2);
|
||||||
|
background: var(--surface-sunken);
|
||||||
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
font-weight: 560;
|
font-weight: 560;
|
||||||
@@ -1370,21 +1493,31 @@
|
|||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Projects inside a group are indented to the group's name, not to its dot:
|
/* Projects sit at the same left margin whether or not they are in an area —
|
||||||
the eye follows the text, and lining up under the dot leaves the names
|
one straight edge down the whole sidebar reads calmer than a staircase, and
|
||||||
looking a step short of where the heading starts. */
|
the bold area heading above already does the grouping the indent used to. */
|
||||||
.folder > a {
|
.folder > a {
|
||||||
padding-left: calc(var(--s2) + var(--s4));
|
padding-left: var(--s2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The line is indented with them, so a drop between two projects in a group
|
|
||||||
reads as landing in the group rather than across the whole sidebar. */
|
|
||||||
.folder > a.sortable::before {
|
.folder > a.sortable::before {
|
||||||
left: calc(var(--s2) + var(--s4));
|
left: var(--s2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.folder > .empty {
|
.folder > .empty {
|
||||||
padding-left: calc(var(--s2) + var(--s4));
|
padding-left: var(--s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* An area is the sidebar's own heading — Things treats it as bold and plainly
|
||||||
|
cased, a thing the eye lands on, not another faint uppercase label like the
|
||||||
|
system sections. So an area's name is set apart while "Projekter", "Filtre"
|
||||||
|
and "Etiketter" stay quiet. */
|
||||||
|
.folder-head h2 {
|
||||||
|
font-size: var(--menu-size);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inside the rename form now, so it needs no padding of its own — the form
|
/* Inside the rename form now, so it needs no padding of its own — the form
|
||||||
@@ -1441,7 +1574,10 @@
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: var(--text-sm);
|
/* Its own size (Settings → Appearance) and medium weight, so the menu items
|
||||||
|
carry more than the muted small text they used to and read at a glance. */
|
||||||
|
font-size: var(--menu-size);
|
||||||
|
font-weight: 500;
|
||||||
transition:
|
transition:
|
||||||
background var(--fast) var(--ease),
|
background var(--fast) var(--ease),
|
||||||
color var(--fast) var(--ease);
|
color var(--fast) var(--ease);
|
||||||
@@ -1452,10 +1588,14 @@
|
|||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* An accent-tinted selection, not a half-step of surface: on the sunken dark
|
||||||
|
sidebar --surface-raised was all but invisible, so the active row read as no
|
||||||
|
different from the rest. --accent-sunken carries the accent hue and stands out
|
||||||
|
in both themes. */
|
||||||
a.active {
|
a.active {
|
||||||
background: var(--surface-raised);
|
background: var(--accent-sunken);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Only your own projects reorder. A shared one sits where its owner put it —
|
/* Only your own projects reorder. A shared one sits where its owner put it —
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { app, projectName, clockOf } from '$lib/stores.svelte.js';
|
import { app, projectName, clockOf } from '$lib/stores.svelte.js';
|
||||||
import { focusOnMount } from '$lib/focus.js';
|
import { focusOnMount } from '$lib/focus.js';
|
||||||
import { t } from '$lib/i18n.svelte.js';
|
import { t, tag } from '$lib/i18n.svelte.js';
|
||||||
import { REPEATS } from '$lib/when.js';
|
import { REPEATS } from '$lib/when.js';
|
||||||
|
|
||||||
let { task, onclose } = $props();
|
let { task, onclose } = $props();
|
||||||
@@ -26,6 +26,46 @@
|
|||||||
// Length in minutes, as a string for the field. Only meaningful with a time —
|
// Length in minutes, as a string for the field. Only meaningful with a time —
|
||||||
// see the field below — so it is seeded and shown alongside the clock.
|
// see the field below — so it is seeded and shown alongside the clock.
|
||||||
let duration = $state('');
|
let duration = $state('');
|
||||||
|
let snoozeCustom = $state('');
|
||||||
|
|
||||||
|
const snoozed = $derived(!!task?.snoozed_until && new Date(task.snoozed_until) > new Date());
|
||||||
|
const snoozedText = $derived(
|
||||||
|
snoozed
|
||||||
|
? new Date(task.snoozed_until).toLocaleString(tag(), {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
|
||||||
|
async function snoozeTo(iso) {
|
||||||
|
snoozeCustom = '';
|
||||||
|
try {
|
||||||
|
await app.snooze(task.id, iso);
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick picks, in local time, as ISO for the API. This evening is six; the
|
||||||
|
// morning ones are nine; next week is the coming Monday.
|
||||||
|
const atLocal = (d, h) => {
|
||||||
|
d.setHours(h, 0, 0, 0);
|
||||||
|
return d.toISOString();
|
||||||
|
};
|
||||||
|
const thisEvening = () => atLocal(new Date(), 18);
|
||||||
|
const tomorrowMorning = () => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
return atLocal(d, 9);
|
||||||
|
};
|
||||||
|
const nextWeek = () => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() + (((8 - d.getDay()) % 7) || 7));
|
||||||
|
return atLocal(d, 9);
|
||||||
|
};
|
||||||
let labels = $state('');
|
let labels = $state('');
|
||||||
let projectId = $state('');
|
let projectId = $state('');
|
||||||
let recurrence = $state('');
|
let recurrence = $state('');
|
||||||
@@ -459,6 +499,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Snooze: park it out of the way until a moment, without touching when it is
|
||||||
|
due. While snoozed the row greys and sinks to the bottom of its list. -->
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-label">{t('detail.snooze')}</span>
|
||||||
|
{#if snoozed}
|
||||||
|
<div class="snoozed-now">
|
||||||
|
<span>{t('task.snoozedUntil', { when: snoozedText })}</span>
|
||||||
|
<button class="snooze-btn" onclick={() => snoozeTo('')}>{t('detail.wake')}</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="snooze-row">
|
||||||
|
<button class="snooze-btn" onclick={() => snoozeTo(thisEvening())}>{t('detail.thisEvening')}</button>
|
||||||
|
<button class="snooze-btn" onclick={() => snoozeTo(tomorrowMorning())}>{t('detail.tomorrow')}</button>
|
||||||
|
<button class="snooze-btn" onclick={() => snoozeTo(nextWeek())}>{t('detail.nextWeek')}</button>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
class="snooze-custom"
|
||||||
|
aria-label={t('detail.snoozeCustom')}
|
||||||
|
bind:value={snoozeCustom}
|
||||||
|
onchange={() => snoozeCustom && snoozeTo(new Date(snoozeCustom).toISOString())}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="labels">{t('detail.labels')}</label>
|
<label for="labels">{t('detail.labels')}</label>
|
||||||
<input
|
<input
|
||||||
@@ -765,13 +830,47 @@
|
|||||||
gap: var(--s2);
|
gap: var(--s2);
|
||||||
}
|
}
|
||||||
|
|
||||||
label {
|
label,
|
||||||
|
.field-label {
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.snooze-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--s1);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.snooze-btn {
|
||||||
|
width: auto;
|
||||||
|
padding: var(--s1) var(--s2);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--ink);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.snooze-btn:hover {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
.snooze-custom {
|
||||||
|
width: auto;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 9rem;
|
||||||
|
}
|
||||||
|
.snoozed-now {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--s2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
input,
|
input,
|
||||||
textarea,
|
textarea,
|
||||||
select {
|
select {
|
||||||
@@ -1016,5 +1115,21 @@
|
|||||||
width: auto;
|
width: auto;
|
||||||
border-left: 0;
|
border-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Two columns is half a phone each: Prioritet collapses to "Ing" and the
|
||||||
|
due-row (date + fixed-width time + duration) runs off the right edge. One
|
||||||
|
column per field gives each its full width; the due-row wraps if even that
|
||||||
|
is not enough. */
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.due {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.due input[type='date'] {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -120,7 +120,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let ordered = $derived([...tasks].sort((a, b) => a.sort_order - b.sort_order));
|
// A snoozed task sinks below the rest, then rises back when its time passes —
|
||||||
|
// the same order the server sorts a list in, kept here so a drag-sorted list
|
||||||
|
// agrees rather than jumping when it reloads.
|
||||||
|
const snoozedNow = (t) => t.snoozed_until && new Date(t.snoozed_until) > new Date();
|
||||||
|
let ordered = $derived(
|
||||||
|
[...tasks].sort((a, b) => {
|
||||||
|
const sa = snoozedNow(a) ? 1 : 0;
|
||||||
|
const sb = snoozedNow(b) ? 1 : 0;
|
||||||
|
if (sa !== sb) return sa - sb;
|
||||||
|
return a.sort_order - b.sort_order;
|
||||||
|
})
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#each ordered as task (task.id)}
|
{#each ordered as task (task.id)}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import { app } from '$lib/stores.svelte.js';
|
import { app } from '$lib/stores.svelte.js';
|
||||||
import { t, tag } from '$lib/i18n.svelte.js';
|
import { t, tag } from '$lib/i18n.svelte.js';
|
||||||
import { repeatLabel } from '$lib/when.js';
|
import { repeatLabel } from '$lib/when.js';
|
||||||
|
import { linkify } from '$lib/linkify.js';
|
||||||
|
|
||||||
let { task, onedit } = $props();
|
let { task, onedit } = $props();
|
||||||
|
|
||||||
@@ -63,9 +64,30 @@
|
|||||||
})
|
})
|
||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Snoozed while its time is still ahead. Greys the row and adds a "slumret til"
|
||||||
|
// mark, so a parked task reads as set-aside rather than forgotten.
|
||||||
|
let snoozed = $derived(task.snoozed_until && new Date(task.snoozed_until) > new Date());
|
||||||
|
let snoozedLabel = $derived.by(() => {
|
||||||
|
if (!snoozed) return null;
|
||||||
|
const at = new Date(task.snoozed_until);
|
||||||
|
const midnight = new Date();
|
||||||
|
midnight.setHours(0, 0, 0, 0);
|
||||||
|
const days = Math.round((new Date(at.toDateString()) - midnight) / 86400000);
|
||||||
|
const clock = at.toLocaleTimeString(tag(), { hour: '2-digit', minute: '2-digit' });
|
||||||
|
if (days <= 0) return clock;
|
||||||
|
if (days === 1) return t('task.tomorrow') + ' ' + clock;
|
||||||
|
return at.toLocaleDateString(tag(), { day: 'numeric', month: 'short' }) + ' ' + clock;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="row" class:leaving class:completed={task.completed} data-priority={task.priority}>
|
<div
|
||||||
|
class="row"
|
||||||
|
class:leaving
|
||||||
|
class:completed={task.completed}
|
||||||
|
class:snoozed
|
||||||
|
data-priority={task.priority}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
class="check"
|
class="check"
|
||||||
class:checked={task.completed || leaving}
|
class:checked={task.completed || leaving}
|
||||||
@@ -78,13 +100,44 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Opening the detail drawer is the default. `onedit` is for the callers that
|
<!-- A URL pasted into the title or the description is clickable, without the
|
||||||
want the click to mean something else. -->
|
text ever having been anything but plain text. The link is a real <a>, which
|
||||||
<button class="body" onclick={() => (onedit ? onedit(task) : app.openDetail(task.id))}>
|
an <a> in a <button> may not be — so the body is a role=button rather than a
|
||||||
<span class="content">{task.content}</span>
|
button, and each link stops its click from also opening the task. Clicking
|
||||||
|
any plain text still opens it. -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="body"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onclick={() => (onedit ? onedit(task) : app.openDetail(task.id))}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onedit ? onedit(task) : app.openDetail(task.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class="content"
|
||||||
|
>{#each linkify(task.content) as run}{#if run.href}<a
|
||||||
|
class="tlink"
|
||||||
|
href={run.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onclick={(e) => e.stopPropagation()}>{run.text}</a
|
||||||
|
>{:else}{run.text}{/if}{/each}</span
|
||||||
|
>
|
||||||
|
|
||||||
{#if task.description}
|
{#if task.description}
|
||||||
<span class="description">{task.description}</span>
|
<span class="description"
|
||||||
|
>{#each linkify(task.description) as run}{#if run.href}<a
|
||||||
|
class="tlink"
|
||||||
|
href={run.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onclick={(e) => e.stopPropagation()}>{run.text}</a
|
||||||
|
>{:else}{run.text}{/if}{/each}</span
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if assignee || due || task.labels?.length || repeats || task.subtask_count || task.attachment_count}
|
{#if assignee || due || task.labels?.length || repeats || task.subtask_count || task.attachment_count}
|
||||||
@@ -92,14 +145,28 @@
|
|||||||
{#if assignee}
|
{#if assignee}
|
||||||
<!-- First in the row, because "who" changes what the rest of the line
|
<!-- First in the row, because "who" changes what the rest of the line
|
||||||
means: a date on somebody else's task is their deadline, not
|
means: a date on somebody else's task is their deadline, not
|
||||||
yours. -->
|
yours. The hand-off arrow marks it as delegated — out of your
|
||||||
<span class="assignee" title={t('task.assignee', { name: assignee.name })}>
|
hands and into theirs — so a list reads at a glance as which are
|
||||||
|
yours to do and which you are waiting on. -->
|
||||||
|
<span class="assignee" title={t('task.delegatedTo', { name: assignee.name })}>
|
||||||
|
<svg class="handoff" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M13 5l7 7-7 7" />
|
||||||
|
<path d="M20 12H4" />
|
||||||
|
</svg>
|
||||||
<span class="face" style="background: {assignee.avatar_color}">
|
<span class="face" style="background: {assignee.avatar_color}">
|
||||||
{assignee.name[0]?.toUpperCase() ?? '?'}
|
{assignee.name[0]?.toUpperCase() ?? '?'}
|
||||||
</span>
|
</span>
|
||||||
{assignee.name}
|
{assignee.name}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if snoozed}
|
||||||
|
<span class="snooze-mark" title={t('task.snoozedUntil', { when: snoozedLabel })}>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M4 6h8l-8 8h8" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
{snoozedLabel}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
{#if due}
|
{#if due}
|
||||||
<span class="due" data-tone={due.tone}>
|
<span class="due" data-tone={due.tone}>
|
||||||
{due.text}{#if time} {time}{/if}
|
{due.text}{#if time} {time}{/if}
|
||||||
@@ -151,7 +218,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -183,6 +250,26 @@
|
|||||||
text-decoration-color: var(--ink-faint);
|
text-decoration-color: var(--ink-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Parked, not gone: dimmed so it reads as set aside, but still there and still
|
||||||
|
readable. The checkbox and the snooze mark keep their weight, so it can be
|
||||||
|
woken or ticked off without peering at it. */
|
||||||
|
.row.snoozed .content,
|
||||||
|
.row.snoozed .description,
|
||||||
|
.row.snoozed .meta {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.snooze-mark {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.snooze-mark svg {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.check {
|
.check {
|
||||||
flex: none;
|
flex: none;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
@@ -252,6 +339,13 @@
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.body:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
@@ -260,6 +354,20 @@
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A real link, so it can be middle-clicked, copied and previewed. Its own click
|
||||||
|
is stopped from bubbling, so opening the page does not also open the task. */
|
||||||
|
.tlink {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tlink:hover {
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.description {
|
.description {
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
@@ -309,8 +417,25 @@
|
|||||||
.assignee {
|
.assignee {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--s1);
|
gap: 4px;
|
||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
padding: 1px var(--s2) 1px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The delegation mark: an arrow leaving, in the accent so the chip reads as
|
||||||
|
handed-off rather than as just another name. */
|
||||||
|
.handoff {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--accent);
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.face {
|
.face {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Splits a plain string into runs of text and the URLs inside it, so a task's
|
||||||
|
* title or description can be shown with the links clickable without the text
|
||||||
|
* ever having been anything but plain text on disk.
|
||||||
|
*
|
||||||
|
* A task is not rich text — the title is an `<input>` and the description a
|
||||||
|
* `<textarea>` — so there is nowhere to *store* a link. What there is instead is
|
||||||
|
* a person who pasted one in and expects to be able to click it. This finds the
|
||||||
|
* ones that are unambiguously links and leaves everything else exactly as typed.
|
||||||
|
*
|
||||||
|
* Deliberately conservative about what counts as a link: an explicit
|
||||||
|
* `http://`/`https://`, or a bare `www.` host. "example.com" on its own is not
|
||||||
|
* matched — most of those are sentences ("the file.txt is here"), and a false
|
||||||
|
* link in a title is worse than a real one left plain.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// A trailing `.,;:!?` or a closing bracket is almost always punctuation the
|
||||||
|
// sentence owns rather than part of the URL, so the run stops before it. A `)`
|
||||||
|
// is only dropped when the URL holds no `(` of its own — Wikipedia-style links
|
||||||
|
// carry balanced parentheses that belong to the address.
|
||||||
|
const URL_RE = /\b(?:https?:\/\/|www\.)[^\s<>]+/gi;
|
||||||
|
|
||||||
|
function trimTrailing(url) {
|
||||||
|
let end = url.length;
|
||||||
|
while (end > 0) {
|
||||||
|
const ch = url[end - 1];
|
||||||
|
if ('.,;:!?"\''.includes(ch)) {
|
||||||
|
end--;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === ')' && !url.slice(0, end).includes('(')) {
|
||||||
|
end--;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return url.slice(0, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} text
|
||||||
|
* @returns {Array<{ text: string, href?: string }>} runs in order; a run with an
|
||||||
|
* `href` is a link, one without is plain text. An empty or link-free string
|
||||||
|
* comes back as a single text run (possibly empty) so callers can render it the
|
||||||
|
* same way regardless.
|
||||||
|
*/
|
||||||
|
export function linkify(text) {
|
||||||
|
const value = text ?? '';
|
||||||
|
const runs = [];
|
||||||
|
let last = 0;
|
||||||
|
|
||||||
|
for (const match of value.matchAll(URL_RE)) {
|
||||||
|
const raw = trimTrailing(match[0]);
|
||||||
|
if (!raw) continue;
|
||||||
|
const start = match.index;
|
||||||
|
|
||||||
|
if (start > last) runs.push({ text: value.slice(last, start) });
|
||||||
|
|
||||||
|
const href = raw.startsWith('www.') ? `https://${raw}` : raw;
|
||||||
|
runs.push({ text: raw, href });
|
||||||
|
last = start + raw.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (last < value.length) runs.push({ text: value.slice(last) });
|
||||||
|
if (!runs.length) runs.push({ text: value });
|
||||||
|
|
||||||
|
return runs;
|
||||||
|
}
|
||||||
@@ -188,6 +188,7 @@ export const da = {
|
|||||||
'repeat.everyN.month': 'hver {n}. måned',
|
'repeat.everyN.month': 'hver {n}. måned',
|
||||||
'repeat.everyN.year': 'hvert {n}. år',
|
'repeat.everyN.year': 'hvert {n}. år',
|
||||||
'task.assignee': 'Ansvarlig: {name}',
|
'task.assignee': 'Ansvarlig: {name}',
|
||||||
|
'task.delegatedTo': 'Uddelegeret til {name}',
|
||||||
'task.subtasks': '{done} af {total} undertasks er lukket',
|
'task.subtasks': '{done} af {total} undertasks er lukket',
|
||||||
'task.attachments': '{n} vedhæftet',
|
'task.attachments': '{n} vedhæftet',
|
||||||
'task.priority': 'Prioritet {n}',
|
'task.priority': 'Prioritet {n}',
|
||||||
@@ -229,6 +230,14 @@ export const da = {
|
|||||||
'Gemmes i denne browser. Det er en egenskab ved den skærm, du sidder ved — en bærbar i solen og en skærm i et mørkt rum vil have hvert sit svar.',
|
'Gemmes i denne browser. Det er en egenskab ved den skærm, du sidder ved — en bærbar i solen og en skærm i et mørkt rum vil have hvert sit svar.',
|
||||||
'account.look': 'Udseende',
|
'account.look': 'Udseende',
|
||||||
'account.lookHint': 'Temaet siger hvor lyst. Det her siger hvordan det læses — skrift, luft og hjørner. De to kan kombineres frit.',
|
'account.lookHint': 'Temaet siger hvor lyst. Det her siger hvordan det læses — skrift, luft og hjørner. De to kan kombineres frit.',
|
||||||
|
'account.sizes': 'Skriftstørrelse',
|
||||||
|
'account.sizesHint': 'Menuen og brødteksten kan sættes hver for sig — den ene er et blik ned over en liste, den anden en tekst, man læser.',
|
||||||
|
'account.menuSize': 'Menu',
|
||||||
|
'account.textSize': 'Brødtekst',
|
||||||
|
'size.small': 'Lille',
|
||||||
|
'size.default': 'Standard',
|
||||||
|
'size.large': 'Stor',
|
||||||
|
'size.xl': 'Størst',
|
||||||
'look.verdande': 'Verdande',
|
'look.verdande': 'Verdande',
|
||||||
'look.verdandeNote': 'Som du kender den.',
|
'look.verdandeNote': 'Som du kender den.',
|
||||||
'look.rolig': 'Rolig',
|
'look.rolig': 'Rolig',
|
||||||
@@ -265,6 +274,10 @@ export const da = {
|
|||||||
'Hvor du er logget ind. Genkender du ikke en af dem, så log den ud og skift adgangskode.',
|
'Hvor du er logget ind. Genkender du ikke en af dem, så log den ud og skift adgangskode.',
|
||||||
'account.thisDevice': 'denne enhed',
|
'account.thisDevice': 'denne enhed',
|
||||||
'account.signOutDevice': 'Log ud',
|
'account.signOutDevice': 'Log ud',
|
||||||
|
'account.support': 'Støt projektet',
|
||||||
|
'account.supportHint':
|
||||||
|
'verdande er fri, open source-software under MIT-licensen — ingen betalt version, intet holdt tilbage. Sparede den dig en eftermiddag, kan du give vedligeholderen en kop kaffe. Helt frivilligt, og intet i appen ændrer sig.',
|
||||||
|
'account.buyCoffee': 'Buy me a coffee',
|
||||||
|
|
||||||
'account.totpEnabled': 'Slået til.',
|
'account.totpEnabled': 'Slået til.',
|
||||||
'account.recoveryLeftOne': '{n} gendannelseskode tilbage.',
|
'account.recoveryLeftOne': '{n} gendannelseskode tilbage.',
|
||||||
@@ -600,6 +613,13 @@ export const da = {
|
|||||||
'detail.due': 'Forfalder',
|
'detail.due': 'Forfalder',
|
||||||
'detail.dueTime': 'Klokkeslæt',
|
'detail.dueTime': 'Klokkeslæt',
|
||||||
'detail.duration': 'Længde i minutter',
|
'detail.duration': 'Længde i minutter',
|
||||||
|
'detail.snooze': 'Slumre',
|
||||||
|
'detail.wake': 'Væk nu',
|
||||||
|
'detail.thisEvening': 'I aften',
|
||||||
|
'detail.tomorrow': 'I morgen',
|
||||||
|
'detail.nextWeek': 'Næste uge',
|
||||||
|
'detail.snoozeCustom': 'Vælg tidspunkt',
|
||||||
|
'task.snoozedUntil': 'Slumret til {when}',
|
||||||
'detail.durationShort': 'min',
|
'detail.durationShort': 'min',
|
||||||
'detail.labels': 'Etiketter',
|
'detail.labels': 'Etiketter',
|
||||||
'detail.labelsPlaceholder': 'adskilt af komma',
|
'detail.labelsPlaceholder': 'adskilt af komma',
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ export const en = {
|
|||||||
'repeat.everyN.month': 'every {n} months',
|
'repeat.everyN.month': 'every {n} months',
|
||||||
'repeat.everyN.year': 'every {n} years',
|
'repeat.everyN.year': 'every {n} years',
|
||||||
'task.assignee': 'Assigned to {name}',
|
'task.assignee': 'Assigned to {name}',
|
||||||
|
'task.delegatedTo': 'Delegated to {name}',
|
||||||
'task.subtasks': '{done} of {total} sub-tasks are done',
|
'task.subtasks': '{done} of {total} sub-tasks are done',
|
||||||
'task.attachments': '{n} attached',
|
'task.attachments': '{n} attached',
|
||||||
'task.priority': 'Priority {n}',
|
'task.priority': 'Priority {n}',
|
||||||
@@ -225,6 +226,14 @@ export const en = {
|
|||||||
'Kept in this browser. It is a property of the screen you are sitting at — a laptop in the sun and a monitor in a dark room want different answers.',
|
'Kept in this browser. It is a property of the screen you are sitting at — a laptop in the sun and a monitor in a dark room want different answers.',
|
||||||
'account.look': 'Appearance',
|
'account.look': 'Appearance',
|
||||||
'account.lookHint': 'The theme says how bright. This says how it reads — typeface, air and corners. The two combine freely.',
|
'account.lookHint': 'The theme says how bright. This says how it reads — typeface, air and corners. The two combine freely.',
|
||||||
|
'account.sizes': 'Text size',
|
||||||
|
'account.sizesHint': 'The menu and the body text set separately — one is a glance down a list, the other a paragraph you read.',
|
||||||
|
'account.menuSize': 'Menu',
|
||||||
|
'account.textSize': 'Body text',
|
||||||
|
'size.small': 'Small',
|
||||||
|
'size.default': 'Default',
|
||||||
|
'size.large': 'Large',
|
||||||
|
'size.xl': 'Largest',
|
||||||
'look.verdande': 'Verdande',
|
'look.verdande': 'Verdande',
|
||||||
'look.verdandeNote': 'As you know it.',
|
'look.verdandeNote': 'As you know it.',
|
||||||
'look.rolig': 'Calm',
|
'look.rolig': 'Calm',
|
||||||
@@ -261,6 +270,10 @@ export const en = {
|
|||||||
'Where you are signed in. If you do not recognise one, sign it out and change your password.',
|
'Where you are signed in. If you do not recognise one, sign it out and change your password.',
|
||||||
'account.thisDevice': 'this device',
|
'account.thisDevice': 'this device',
|
||||||
'account.signOutDevice': 'Sign out',
|
'account.signOutDevice': 'Sign out',
|
||||||
|
'account.support': 'Support the project',
|
||||||
|
'account.supportHint':
|
||||||
|
'verdande is free, open-source software under the MIT licence — no paid tier, nothing held back. If it saved you an afternoon, you can buy the maintainer a coffee. Entirely optional, and nothing in the app changes either way.',
|
||||||
|
'account.buyCoffee': 'Buy me a coffee',
|
||||||
|
|
||||||
'account.totpEnabled': 'On.',
|
'account.totpEnabled': 'On.',
|
||||||
'account.recoveryLeftOne': '{n} recovery code left.',
|
'account.recoveryLeftOne': '{n} recovery code left.',
|
||||||
@@ -596,6 +609,13 @@ export const en = {
|
|||||||
'detail.due': 'Due',
|
'detail.due': 'Due',
|
||||||
'detail.dueTime': 'Time',
|
'detail.dueTime': 'Time',
|
||||||
'detail.duration': 'Length in minutes',
|
'detail.duration': 'Length in minutes',
|
||||||
|
'detail.snooze': 'Snooze',
|
||||||
|
'detail.wake': 'Wake now',
|
||||||
|
'detail.thisEvening': 'This evening',
|
||||||
|
'detail.tomorrow': 'Tomorrow',
|
||||||
|
'detail.nextWeek': 'Next week',
|
||||||
|
'detail.snoozeCustom': 'Pick a time',
|
||||||
|
'task.snoozedUntil': 'Snoozed until {when}',
|
||||||
'detail.durationShort': 'min',
|
'detail.durationShort': 'min',
|
||||||
'detail.labels': 'Labels',
|
'detail.labels': 'Labels',
|
||||||
'detail.labelsPlaceholder': 'separated by commas',
|
'detail.labelsPlaceholder': 'separated by commas',
|
||||||
|
|||||||
@@ -408,6 +408,19 @@ class AppState {
|
|||||||
* and time are left alone, so making a task longer on the calendar does not
|
* and time are left alone, so making a task longer on the calendar does not
|
||||||
* also reschedule it.
|
* also reschedule it.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Snoozes a task until an ISO time, or wakes it when `until` is empty. Nothing
|
||||||
|
* else about the task moves — a snooze is about when you want to see it, not when
|
||||||
|
* it is due. Optimistic, so it greys and sinks at once.
|
||||||
|
*/
|
||||||
|
async snooze(id, until) {
|
||||||
|
await this.#optimistic(
|
||||||
|
id,
|
||||||
|
{ snoozed_until: until || undefined },
|
||||||
|
() => api.snoozeTask(id, until || '')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async resize(id, minutes) {
|
async resize(id, minutes) {
|
||||||
const previous = this.get(id);
|
const previous = this.get(id);
|
||||||
if (!previous) return;
|
if (!previous) return;
|
||||||
@@ -1038,6 +1051,52 @@ class Look {
|
|||||||
|
|
||||||
export const look = new Look();
|
export const look = new Look();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The font-size steps, shared by the menu and the body-text controls. `default`
|
||||||
|
* carries no attribute, so the base tokens in app.css stand unchanged.
|
||||||
|
*/
|
||||||
|
export const SIZES = [
|
||||||
|
{ id: 'small', name: 'size.small' },
|
||||||
|
{ id: 'default', name: 'size.default' },
|
||||||
|
{ id: 'large', name: 'size.large' },
|
||||||
|
{ id: 'xl', name: 'size.xl' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A font-size preference — the same shape as Look: kept in localStorage and
|
||||||
|
* mirrored onto <html>, where app.html sets it before first paint so a chosen size
|
||||||
|
* does not flash in at the default and resize. Two instances below, because the
|
||||||
|
* menu and the body text are separate questions.
|
||||||
|
*/
|
||||||
|
class Sizing {
|
||||||
|
current = $state('default');
|
||||||
|
#key;
|
||||||
|
#attr;
|
||||||
|
|
||||||
|
constructor(key, attr) {
|
||||||
|
this.#key = key;
|
||||||
|
this.#attr = attr;
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
this.current = document.documentElement.dataset[attr] ?? 'default';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set(id) {
|
||||||
|
if (!SIZES.some((s) => s.id === id)) return;
|
||||||
|
this.current = id;
|
||||||
|
if (id === 'default') delete document.documentElement.dataset[this.#attr];
|
||||||
|
else document.documentElement.dataset[this.#attr] = id;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(this.#key, id);
|
||||||
|
} catch {
|
||||||
|
// Private browsing; the choice simply will not persist.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const menuSize = new Sizing('verdande:menu-size', 'menuSize');
|
||||||
|
export const textSize = new Sizing('verdande:text-size', 'textSize');
|
||||||
|
|
||||||
/** Theme, kept in localStorage and applied to the document element. */
|
/** Theme, kept in localStorage and applied to the document element. */
|
||||||
class Theme {
|
class Theme {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
/** Konto: who you are, your password, and the second factor. */
|
/** Konto: who you are, your password, and the second factor. */
|
||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { app, theme, THEMES, look, LOOKS } from '$lib/stores.svelte.js';
|
import { app, theme, THEMES, look, LOOKS, menuSize, textSize, SIZES } from '$lib/stores.svelte.js';
|
||||||
import { t, plural, i18n } from '$lib/i18n.svelte.js';
|
import { t, plural, i18n } from '$lib/i18n.svelte.js';
|
||||||
import { supported, register } from '$lib/passkey.js';
|
import { supported, register } from '$lib/passkey.js';
|
||||||
import { ago } from '$lib/when.js';
|
import { ago } from '$lib/when.js';
|
||||||
@@ -362,6 +362,40 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Two more axes: how large the menu reads and how large the body text reads.
|
||||||
|
Separate on purpose — one is a glance down a list of names, the other is a
|
||||||
|
paragraph you sit and read. -->
|
||||||
|
<div class="sizes">
|
||||||
|
<h3>{t('account.sizes')}</h3>
|
||||||
|
<p class="hint">{t('account.sizesHint')}</p>
|
||||||
|
<div class="size-row">
|
||||||
|
<div class="size-field">
|
||||||
|
<label for="menu-size">{t('account.menuSize')}</label>
|
||||||
|
<select
|
||||||
|
id="menu-size"
|
||||||
|
value={menuSize.current}
|
||||||
|
onchange={(e) => menuSize.set(e.currentTarget.value)}
|
||||||
|
>
|
||||||
|
{#each SIZES as option (option.id)}
|
||||||
|
<option value={option.id}>{t(option.name)}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="size-field">
|
||||||
|
<label for="text-size">{t('account.textSize')}</label>
|
||||||
|
<select
|
||||||
|
id="text-size"
|
||||||
|
value={textSize.current}
|
||||||
|
onchange={(e) => textSize.set(e.currentTarget.value)}
|
||||||
|
>
|
||||||
|
{#each SIZES as option (option.id)}
|
||||||
|
<option value={option.id}>{t(option.name)}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
@@ -571,6 +605,42 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Last, and quietly: a donate link belongs in Settings, never in the way of the
|
||||||
|
work. A plain anchor with an inline SVG — no third-party script, no webfont,
|
||||||
|
no tracking — so it works offline and cannot phone home. See the coffee-cup
|
||||||
|
snippet for why not their generated button. -->
|
||||||
|
<section class="panel">
|
||||||
|
<header>
|
||||||
|
<h2>{t('account.support')}</h2>
|
||||||
|
<p class="hint">{t('account.supportHint')}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<a
|
||||||
|
class="bmc"
|
||||||
|
href="https://buymeacoffee.com/kristianwind"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
|
>
|
||||||
|
<path d="M4 9h13v6a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V9Z" />
|
||||||
|
<path d="M17 10h1.5a2.5 2.5 0 0 1 0 5H17" />
|
||||||
|
<path d="M7.5 5.6c0-.9.8-1.1.8-2M11 5.6c0-.9.8-1.1.8-2M14.5 5.6c0-.9.8-1.1.8-2" />
|
||||||
|
</svg>
|
||||||
|
{t('account.buyCoffee')} ↗
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.looks {
|
.looks {
|
||||||
margin-top: var(--s4);
|
margin-top: var(--s4);
|
||||||
@@ -583,6 +653,37 @@
|
|||||||
margin-bottom: var(--s1);
|
margin-bottom: var(--s1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sizes {
|
||||||
|
margin-top: var(--s4);
|
||||||
|
padding-top: var(--s3);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sizes h3 {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
margin-bottom: var(--s1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: var(--s3);
|
||||||
|
margin-top: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-field label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
.look-row {
|
.look-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
@@ -771,4 +872,36 @@
|
|||||||
.uri {
|
.uri {
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The understated variant: present, not loud. Bordered and in the muted ink so
|
||||||
|
it reads as a footnote, brightening on hover. The cup is outline-only. */
|
||||||
|
.bmc {
|
||||||
|
align-self: flex-start;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
padding: var(--s2) var(--s3);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
transition:
|
||||||
|
color var(--fast) var(--ease),
|
||||||
|
border-color var(--fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bmc:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
border-color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bmc:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bmc svg {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1081,6 +1081,11 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
border-right: 1px solid var(--line);
|
border-right: 1px solid var(--line);
|
||||||
padding-right: var(--s3);
|
padding-right: var(--s3);
|
||||||
|
/* White in light, a nuance lighter than the ground in dark — the note list
|
||||||
|
is a surface you read down, so it lifts off the page like the editor does.
|
||||||
|
--surface-raised is #fff in the light themes and one step up in the dark. */
|
||||||
|
background: var(--surface-raised);
|
||||||
|
padding-left: var(--s3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.head {
|
.head {
|
||||||
@@ -1546,7 +1551,9 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
background: var(--surface);
|
/* A nuance lighter than the ground in dark, still white in light — matches
|
||||||
|
the note list beside it so the two read as one writing surface. */
|
||||||
|
background: var(--surface-raised);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
/* Luft nok til at teksten ikke rører kanten.
|
/* Luft nok til at teksten ikke rører kanten.
|
||||||
|
|||||||