Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
982c3aab18 | ||
|
|
a52ad9817f | ||
|
|
0dbf7a40bf | ||
|
|
9cc7df69f0 | ||
|
|
ab5e3e35a6 | ||
|
|
d6d5bf590b | ||
|
|
6853897d00 |
@@ -79,12 +79,18 @@ jobs:
|
||||
working-directory: web
|
||||
run: npm ci --no-audit --no-fund
|
||||
|
||||
# Chromium only. A smoke test exists to catch a build that does not work at
|
||||
# all; running it three times in three engines finds the same breakage three
|
||||
# times and costs three times as much.
|
||||
- name: Install Chromium
|
||||
# Chromium for the suite, WebKit for one test.
|
||||
#
|
||||
# A smoke test exists to catch a build that does not work at all, and running
|
||||
# the whole thing in three engines finds the same breakage three times for
|
||||
# three times the cost. Focus is the exception, and it took a real report to
|
||||
# learn it: Safari ignores `autofocus` on anything inserted after the page has
|
||||
# loaded, so every inline form in the app opened with no cursor in it. WebKit
|
||||
# here is the same engine Safari is, and `webkit-forms` in the Playwright
|
||||
# config greps a single test out of the suite to run in it.
|
||||
- name: Install browsers
|
||||
working-directory: web
|
||||
run: npx playwright install --with-deps chromium
|
||||
run: npx playwright install --with-deps chromium webkit
|
||||
|
||||
# The suite builds the frontend, embeds it and starts the real binary — see
|
||||
# playwright.config.js. There is nothing to build here first.
|
||||
|
||||
@@ -365,3 +365,59 @@ func TestAProjectCanBeCreatedIntoAGroup(t *testing.T) {
|
||||
t.Errorf("the stored project has group_id = %v, want %q", found["group_id"], groupID)
|
||||
}
|
||||
}
|
||||
|
||||
// A section holds as many tasks as you put in it.
|
||||
//
|
||||
// Reported from use: "sections can only have one task". Driven through the same
|
||||
// endpoint the interface uses, with the same `after_id` the drop handler sends —
|
||||
// the second task goes after the first, which is the case that would fail if
|
||||
// positioning inside a section were wrong.
|
||||
func TestASectionHoldsMoreThanOneTask(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Skovvænget"})
|
||||
projectID := project["id"].(string)
|
||||
_, section := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/sections",
|
||||
map[string]any{"name": "Håndværker"})
|
||||
sectionID := section["id"].(string)
|
||||
|
||||
var previous string
|
||||
for _, content := range []string{"knager på badeværelset", "plade på opvaskemaskine", "fuge om vinduet"} {
|
||||
_, created := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": content, "project_id": projectID,
|
||||
})
|
||||
id := created["id"].(string)
|
||||
|
||||
resp, moved := ts.do(t, "POST", "/api/v1/tasks/"+id+"/move", map[string]any{
|
||||
"project_id": projectID, "section_id": sectionID, "after_id": previous,
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("move %q: status %d, body %v", content, resp.StatusCode, moved)
|
||||
}
|
||||
if moved["section_id"] != sectionID {
|
||||
t.Fatalf("%q did not land in the section: %v", content, moved["section_id"])
|
||||
}
|
||||
previous = id
|
||||
}
|
||||
|
||||
_, listed := ts.do(t, "GET", "/api/v1/tasks?project_id="+projectID, nil)
|
||||
tasks, _ := listed["tasks"].([]any)
|
||||
inSection := []string{}
|
||||
for _, raw := range tasks {
|
||||
task := raw.(map[string]any)
|
||||
if task["section_id"] == sectionID {
|
||||
inSection = append(inSection, task["content"].(string))
|
||||
}
|
||||
}
|
||||
if len(inSection) != 3 {
|
||||
t.Fatalf("the section holds %d tasks, want 3: %v", len(inSection), inSection)
|
||||
}
|
||||
// And in the order they were placed, since each went after the one before it.
|
||||
want := []string{"knager på badeværelset", "plade på opvaskemaskine", "fuge om vinduet"}
|
||||
for i, content := range want {
|
||||
if inSection[i] != content {
|
||||
t.Errorf("position %d is %q, want %q — the whole list: %v", i, inSection[i], content, inSection)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,6 +475,10 @@ type quickAddRequest struct {
|
||||
// ProjectID is the view the user was looking at. A "#project" in the text
|
||||
// wins over it: what somebody typed beats where they happened to be standing.
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
// SectionID is the section the box was opened in, for the field that sits at
|
||||
// the foot of one. Same rule as the project: a "/section" in the text wins,
|
||||
// because typing it is a choice and standing there is a circumstance.
|
||||
SectionID string `json:"section_id,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -518,9 +522,31 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Resolved after the project, because a section belongs to one project and the
|
||||
// same name can exist in several. A name that matches nothing is reported the
|
||||
// same way an unknown project is: the task is still created, in the project
|
||||
// without a section, and the caller is told which word did not land.
|
||||
sectionID, unknownSection := "", ""
|
||||
// The section the box belongs to, unless the text names one. Checked against
|
||||
// the project the task actually landed in, so a "#project" that moved it
|
||||
// elsewhere cannot drag a section id from another project along with it.
|
||||
if req.SectionID != "" {
|
||||
if belongs, err := s.db.SectionInProject(r.Context(), req.SectionID, projectID); err == nil && belongs {
|
||||
sectionID = req.SectionID
|
||||
}
|
||||
}
|
||||
if parsed.Section != "" {
|
||||
if id, err := s.db.SectionByName(r.Context(), projectID, parsed.Section); err == nil {
|
||||
sectionID = id
|
||||
} else {
|
||||
unknownSection = parsed.Section
|
||||
}
|
||||
}
|
||||
|
||||
t := &store.Task{
|
||||
ProjectID: projectID, Content: parsed.Content, Priority: parsed.Priority,
|
||||
CreatedBy: user.ID, DueDate: parsed.DueDate, RecurrenceRule: parsed.Recurrence,
|
||||
ProjectID: projectID, SectionID: sectionID, Content: parsed.Content,
|
||||
Priority: parsed.Priority, CreatedBy: user.ID, DueDate: parsed.DueDate,
|
||||
RecurrenceRule: parsed.Recurrence,
|
||||
}
|
||||
if parsed.DueDate != "" {
|
||||
_, when, err := resolveDue(parsed.DueDate, parsed.DueTime, user.Timezone)
|
||||
@@ -537,13 +563,14 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
|
||||
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
|
||||
|
||||
if unknownProject != "" {
|
||||
if unknownProject != "" || unknownSection != "" {
|
||||
// Alongside the task rather than instead of it: the task was created, and
|
||||
// this says what could not be honoured while creating it.
|
||||
writeJSON(w, http.StatusCreated, struct {
|
||||
taskJSON
|
||||
UnknownProject string `json:"unknown_project"`
|
||||
}{toTaskJSON(*t), unknownProject})
|
||||
UnknownProject string `json:"unknown_project,omitempty"`
|
||||
UnknownSection string `json:"unknown_section,omitempty"`
|
||||
}{toTaskJSON(*t), unknownProject, unknownSection})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toTaskJSON(*t))
|
||||
|
||||
@@ -1682,3 +1682,98 @@ func TestNotifications(t *testing.T) {
|
||||
t.Errorf("unread = %v after marking everything read", after["unread"])
|
||||
}
|
||||
}
|
||||
|
||||
// Quick add can put a task straight into a section.
|
||||
//
|
||||
// A section belongs to one project, so the name is resolved after the project is
|
||||
// decided — and two projects may both have a "Produktion", which are different
|
||||
// sections. A name that matches nothing is reported the way an unknown project is:
|
||||
// the task is still created, and the caller is told which word did not land.
|
||||
func TestQuickAddPutsATaskInASection(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Risteriet"})
|
||||
projectID := project["id"].(string)
|
||||
_, section := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/sections",
|
||||
map[string]any{"name": "Kunder/Ordrer"})
|
||||
sectionID := section["id"].(string)
|
||||
|
||||
// The section name has a slash in it, which is also the sigil. The bare form is
|
||||
// greedy, so the whole name comes across.
|
||||
resp, task := ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||
map[string]any{"text": "Karpenhøj kaffe bestilling #Risteriet /Kunder/Ordrer"})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("quick add: status %d, body %v", resp.StatusCode, task)
|
||||
}
|
||||
if task["section_id"] != sectionID {
|
||||
t.Errorf("section_id = %v, want %v", task["section_id"], sectionID)
|
||||
}
|
||||
if task["content"] != "Karpenhøj kaffe bestilling" {
|
||||
t.Errorf("content = %v — the sigils should be gone", task["content"])
|
||||
}
|
||||
|
||||
// Case-insensitive, like the project sigil.
|
||||
_, lower := ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||
map[string]any{"text": `nye poser #Risteriet /"kunder/ordrer"`})
|
||||
if lower["section_id"] != sectionID {
|
||||
t.Errorf("a differently-cased name did not resolve: %v", lower["section_id"])
|
||||
}
|
||||
|
||||
// A name that matches nothing still creates the task, and says so.
|
||||
_, missing := ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||
map[string]any{"text": "noget andet #Risteriet /Findesikke"})
|
||||
if missing["section_id"] != nil && missing["section_id"] != "" {
|
||||
t.Errorf("an unknown section should leave the task unsectioned: %v", missing["section_id"])
|
||||
}
|
||||
if missing["unknown_section"] != "Findesikke" {
|
||||
t.Errorf("unknown_section = %v, want Findesikke", missing["unknown_section"])
|
||||
}
|
||||
if missing["content"] != "noget andet" {
|
||||
t.Errorf("content = %v", missing["content"])
|
||||
}
|
||||
|
||||
// A section in another project is a different section, and must not be found.
|
||||
_, other := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Andet"})
|
||||
ts.do(t, "POST", "/api/v1/projects/"+other["id"].(string)+"/sections",
|
||||
map[string]any{"name": "Egen"})
|
||||
_, crossed := ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||
map[string]any{"text": "forkert sted #Risteriet /Egen"})
|
||||
if crossed["unknown_section"] != "Egen" {
|
||||
t.Errorf("a section from another project was found: %v", crossed["section_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// The field at the foot of a section passes the section it sits in, and a
|
||||
// "#project" in the same line can move the task out from under it.
|
||||
func TestQuickAddSectionIDIsCheckedAgainstTheProject(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, here := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Her"})
|
||||
hereID := here["id"].(string)
|
||||
_, section := ts.do(t, "POST", "/api/v1/projects/"+hereID+"/sections",
|
||||
map[string]any{"name": "Produktion"})
|
||||
sectionID := section["id"].(string)
|
||||
|
||||
// Standing in the section is enough; no sigil needed.
|
||||
_, plain := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "male gavlen", "project_id": hereID, "section_id": sectionID,
|
||||
})
|
||||
if plain["section_id"] != sectionID {
|
||||
t.Errorf("section_id = %v, want %v", plain["section_id"], sectionID)
|
||||
}
|
||||
|
||||
// A "#project" that moves the task must not drag the section id with it: that
|
||||
// section belongs to the project being left behind.
|
||||
ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Andetsted"})
|
||||
_, moved := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "male gavlen #Andetsted", "project_id": hereID, "section_id": sectionID,
|
||||
})
|
||||
if moved["section_id"] != nil && moved["section_id"] != "" {
|
||||
t.Errorf("the section came along to another project: %v", moved["section_id"])
|
||||
}
|
||||
if moved["content"] != "male gavlen" {
|
||||
t.Errorf("content = %v", moved["content"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Danish prose left in a component is the failure the dictionaries cannot see.
|
||||
//
|
||||
// `i18n_test.go` checks that the two dictionaries agree. It cannot check the thing
|
||||
// that actually went wrong: a string that was never put in either, because nobody
|
||||
// noticed it. Three survived the sweep — "Ingen nyere version fundet.", "Ikke mere
|
||||
// i dag." and a token hint split across two lines — and they survived it for the
|
||||
// same reason. The sweep grepped for æøå, and none of them contain one.
|
||||
//
|
||||
// So this reads the templates instead and looks for the words that give Danish
|
||||
// away whatever letters it happens to use. A word list rather than a language
|
||||
// detector: this repository ships one static binary, and a list of thirty common
|
||||
// words catches the sentences a person would actually write.
|
||||
func TestNoDanishProseOutsideTheDictionaries(t *testing.T) {
|
||||
// Text nodes of three characters or more, and the three attributes a person
|
||||
// reads. Anything inside {…} is already an expression — usually a t() call —
|
||||
// and is not what this is looking for.
|
||||
text := regexp.MustCompile(`>\s*([^<>{}\n][^<>{}]{3,})\s*<`)
|
||||
attrs := regexp.MustCompile(`(?:aria-label|title|placeholder)="([^"{][^"]{3,})"`)
|
||||
// Words that are Danish and are not also English. "is", "for" and "to" would
|
||||
// match English prose and are deliberately absent.
|
||||
danish := regexp.MustCompile(`(?i)\b(og|er|ikke|det|den|der|til|kan|som|har|med|af|en|et|du|din|dine|vises|ingen|nyere|fundet|ude|valgt|slettet|gemt|opgave|projekt|indstillinger|fjern|opret|vælg|luk|tilføj|ryd|gem|slet|omdøb)\b`)
|
||||
comments := regexp.MustCompile(`(?s)<!--.*?-->`)
|
||||
styles := regexp.MustCompile(`(?s)<style>.*`)
|
||||
|
||||
var found []string
|
||||
root := filepath.Join("..", "..", "web", "src")
|
||||
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".svelte") {
|
||||
return err
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
markup := string(body)
|
||||
if at := strings.Index(markup, "</script>"); at > 0 {
|
||||
markup = markup[at:]
|
||||
}
|
||||
markup = styles.ReplaceAllString(comments.ReplaceAllString(markup, ""), "")
|
||||
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
for _, m := range append(text.FindAllStringSubmatch(markup, -1),
|
||||
attrs.FindAllStringSubmatch(markup, -1)...) {
|
||||
phrase := strings.TrimSpace(m[1])
|
||||
if danish.MatchString(phrase) {
|
||||
found = append(found, rel+": "+strings.Join(strings.Fields(phrase), " "))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk %s: %v", root, err)
|
||||
}
|
||||
|
||||
if len(found) > 0 {
|
||||
t.Errorf("%d Danish string(s) are written into a component instead of going through "+
|
||||
"t(). An English interface shows them in Danish, and neither dictionary knows "+
|
||||
"they exist:\n %s", len(found), strings.Join(found, "\n "))
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ const (
|
||||
KindTime Kind = "time"
|
||||
KindPriority Kind = "priority"
|
||||
KindProject Kind = "project"
|
||||
KindSection Kind = "section"
|
||||
KindLabel Kind = "label"
|
||||
KindRepeat Kind = "repeat"
|
||||
)
|
||||
@@ -52,7 +53,11 @@ type Result struct {
|
||||
Content string `json:"content"`
|
||||
// Project is the name written after '#', not an id: the caller resolves it
|
||||
// against the user's projects and decides what to do when it matches nothing.
|
||||
Project string `json:"project,omitempty"`
|
||||
Project string `json:"project,omitempty"`
|
||||
// Section is the name written after '/', resolved by the caller against the
|
||||
// sections of whichever project the task lands in. A section belongs to one
|
||||
// project, so it cannot be looked up until the project is known.
|
||||
Section string `json:"section,omitempty"`
|
||||
Labels []string `json:"labels,omitempty"`
|
||||
// Priority is 1 (highest) to 4. 4 is both the default and "none stated".
|
||||
Priority int `json:"priority"`
|
||||
@@ -81,6 +86,7 @@ func Parse(input string, now time.Time, locale string) Result {
|
||||
// Sigils first. '#' and '@' are unambiguous, and consuming them stops a project
|
||||
// called "Fredag" from later being read as a weekday.
|
||||
res.Project, spans = extractProject(input, spans)
|
||||
res.Section, spans = extractSection(input, spans)
|
||||
res.Labels, spans = extractLabels(input, spans)
|
||||
res.Priority, spans = extractPriority(input, spans)
|
||||
|
||||
@@ -144,6 +150,16 @@ func Parse(input string, now time.Time, locale string) Result {
|
||||
var (
|
||||
reProject = regexp.MustCompile(`(?:^|\s)#(?:"([^"]+)"|([\p{L}\p{N}_/-]+))`)
|
||||
reLabel = regexp.MustCompile(`(?:^|\s)@(?:"([^"]+)"|([\p{L}\p{N}_/-]+))`)
|
||||
// A section, written '/Produktion' or '/"Kunder og ordrer"'.
|
||||
//
|
||||
// The name may itself contain a slash — "Kunder/Ordrer" is a real section
|
||||
// name — so the bare form is greedy and swallows the lot.
|
||||
//
|
||||
// The leading `(?:^|\s)` is what keeps this out of ordinary text. A slash
|
||||
// inside a word never matches, so "km/t" and "3/4" are safe, and the name has
|
||||
// to start immediately after the slash, so "20 kr / stk" is safe too. Those
|
||||
// three are the shapes a slash actually takes in a Danish task line.
|
||||
reSection = regexp.MustCompile(`(?:^|\s)/(?:"([^"]+)"|([\p{L}\p{N}_/-]+))`)
|
||||
)
|
||||
|
||||
func extractProject(input string, spans []Span) (string, []Span) {
|
||||
@@ -160,6 +176,20 @@ func extractProject(input string, spans []Span) (string, []Span) {
|
||||
return name, spans
|
||||
}
|
||||
|
||||
// Last one wins, as with the project: retyping is a correction, not two sections.
|
||||
func extractSection(input string, spans []Span) (string, []Span) {
|
||||
all := reSection.FindAllStringSubmatchIndex(input, -1)
|
||||
if len(all) == 0 {
|
||||
return "", spans
|
||||
}
|
||||
var name string
|
||||
for _, m := range all {
|
||||
name = submatch(input, m)
|
||||
spans = append(spans, sigilSpan(input, m, KindSection))
|
||||
}
|
||||
return name, spans
|
||||
}
|
||||
|
||||
func extractLabels(input string, spans []Span) ([]string, []Span) {
|
||||
var labels []string
|
||||
seen := map[string]bool{}
|
||||
|
||||
@@ -560,3 +560,46 @@ func TestRecurrenceIsHighlighted(t *testing.T) {
|
||||
t.Error("no span of kind repeat")
|
||||
}
|
||||
}
|
||||
|
||||
// A section, and the three shapes a slash takes in ordinary text.
|
||||
//
|
||||
// The sigil is the one that needed thinking about: '#' and '@' never occur in a
|
||||
// Danish task line by accident, and '/' occurs constantly. What keeps it apart is
|
||||
// that the slash must follow whitespace and the name must follow the slash with
|
||||
// nothing in between — which "km/t", "3/4" and "20 kr / stk" all fail.
|
||||
func TestASectionIsWrittenWithASlash(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
in string
|
||||
section string
|
||||
content string
|
||||
}{
|
||||
{"ring til Karpenhøj /Produktion", "Produktion", "ring til Karpenhøj"},
|
||||
{"/Produktion ring til Karpenhøj", "Produktion", "ring til Karpenhøj"},
|
||||
// A real section name with a slash in it. The bare form is greedy, so the
|
||||
// whole thing comes across rather than stopping at the second slash.
|
||||
{"nye poser /Kunder/Ordrer", "Kunder/Ordrer", "nye poser"},
|
||||
{`nye poser /"Kunder og ordrer"`, "Kunder og ordrer", "nye poser"},
|
||||
// Retyping is a correction, not two sections.
|
||||
{"nye poser /Drift /Produktion", "Produktion", "nye poser"},
|
||||
// And alongside everything else.
|
||||
{"betal moms i morgen kl 10 p1 #Firma /Bogholderi", "Bogholderi", "betal moms"},
|
||||
|
||||
// The ways a slash shows up when nobody meant a section. None of them may
|
||||
// produce one: the slash has to follow whitespace, and the name has to
|
||||
// follow the slash with nothing between.
|
||||
{"kør 80 km/t forbi", "", "kør 80 km/t forbi"},
|
||||
{"20 kr / stk hos grossisten", "", "20 kr / stk hos grossisten"},
|
||||
// "3/4" is a section to nobody and a date to a Dane — the 3rd of April —
|
||||
// and the date parser is right to claim it. What matters here is only that
|
||||
// it did not become a section.
|
||||
{"læs 3/4 af rapporten", "", "læs af rapporten"},
|
||||
} {
|
||||
got := Parse(c.in, ref(t), "da")
|
||||
if got.Section != c.section {
|
||||
t.Errorf("Parse(%q).Section = %q, want %q", c.in, got.Section, c.section)
|
||||
}
|
||||
if got.Content != c.content {
|
||||
t.Errorf("Parse(%q).Content = %q, want %q", c.in, got.Content, c.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -630,3 +630,36 @@ func (db *DB) ReorderProjects(ctx context.Context, userID string, ids []string)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// SectionByName resolves a "/name" from quick add within one project.
|
||||
//
|
||||
// Case-insensitive, and scoped to the project the task is landing in — a section
|
||||
// belongs to exactly one project, so the name cannot be looked up before that is
|
||||
// decided. Two projects may both have a "Produktion" and they are different
|
||||
// sections; asking without the project would be asking the wrong question.
|
||||
func (db *DB) SectionByName(ctx context.Context, projectID, name string) (string, error) {
|
||||
var id string
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT id FROM sections
|
||||
WHERE project_id = ? AND lower(name) = lower(?) AND deleted_at IS NULL
|
||||
ORDER BY sort_order
|
||||
LIMIT 1`, projectID, name).Scan(&id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
// SectionInProject reports whether a section id belongs to the given project.
|
||||
//
|
||||
// Quick add takes a section id from the client — the field at the foot of a
|
||||
// section passes the one it sits in — and a "#project" in the same line can move
|
||||
// the task somewhere else entirely. Without this the id would follow it, and the
|
||||
// task would land in a section belonging to a project it is not in.
|
||||
func (db *DB) SectionInProject(ctx context.Context, sectionID, projectID string) (bool, error) {
|
||||
var n int
|
||||
err := db.QueryRowContext(ctx,
|
||||
`SELECT count(*) FROM sections WHERE id = ? AND project_id = ? AND deleted_at IS NULL`,
|
||||
sectionID, projectID).Scan(&n)
|
||||
return n == 1, err
|
||||
}
|
||||
|
||||
+14
-1
@@ -189,7 +189,7 @@ func (db *DB) ListTasks(ctx context.Context, userID string, f TaskFilter) ([]Tas
|
||||
FROM tasks t
|
||||
JOIN projects p ON p.id = t.project_id
|
||||
WHERE ` + strings.Join(where, " AND ") + `
|
||||
ORDER BY t.due_date IS NULL, t.due_date, t.priority, t.sort_order, t.created_at`
|
||||
ORDER BY ` + order(f)
|
||||
|
||||
if f.Limit > 0 {
|
||||
query += ` LIMIT ?`
|
||||
@@ -346,6 +346,19 @@ const taskCounts = `
|
||||
WHERE c.parent_id = t.id AND c.deleted_at IS NULL AND c.completed_at IS NOT NULL),
|
||||
(SELECT count(*) FROM attachments a WHERE a.task_id = t.id)`
|
||||
|
||||
// order is how a list of tasks is sorted, which depends on what the list is for.
|
||||
//
|
||||
// A plan is read by when it is due and how urgent it is. A record of what has been
|
||||
// finished is read backwards from now — "what did I just do" and "what did I close
|
||||
// by mistake" are both questions about the most recent thing, and neither is
|
||||
// answered by a due date the task no longer has.
|
||||
func order(f TaskFilter) string {
|
||||
if f.CompletedOnly {
|
||||
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"
|
||||
}
|
||||
|
||||
func scanTask(rows *sql.Rows) (Task, error) {
|
||||
var t Task
|
||||
var sectionID, parentID, dueDate, dueTZ, recurrence, assignee, completedBy sql.NullString
|
||||
|
||||
+251
-7
@@ -19,6 +19,25 @@ import { USER } from './user.js';
|
||||
* happened" is not an actionable test failure — "PATCH /api/v1/tasks/x returned
|
||||
* 500" is.
|
||||
*/
|
||||
/**
|
||||
* Opens the project header's overflow menu and clicks one of its items.
|
||||
*
|
||||
* The header used to carry eight controls in one row — title, colour, show-done,
|
||||
* three view buttons, share, history, delete — and a project name is not a short
|
||||
* word, so the heading was what gave way. Everything but the view switcher moved
|
||||
* behind one button; this is how a test reaches them.
|
||||
*/
|
||||
async function projectAction(page, name) {
|
||||
await page.getByRole('button', { name: 'Flere handlinger' }).click();
|
||||
// `exact` only means anything for a string. The share item reads "Delt · 2" once
|
||||
// somebody is in the project, so callers pass a regex for that one.
|
||||
const item =
|
||||
name instanceof RegExp
|
||||
? page.getByRole('menuitem', { name })
|
||||
: page.getByRole('menuitem', { name, exact: true });
|
||||
await item.click();
|
||||
}
|
||||
|
||||
function watchForTrouble(page) {
|
||||
const trouble = [];
|
||||
page.on('console', (message) => {
|
||||
@@ -224,7 +243,7 @@ test('et projekt kan omdøbes, slettes og hentes tilbage', async ({ page }) => {
|
||||
await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeVisible();
|
||||
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await page.getByRole('button', { name: 'Slet projektet' }).click();
|
||||
await projectAction(page, 'Slet projektet');
|
||||
|
||||
await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeHidden();
|
||||
|
||||
@@ -540,7 +559,7 @@ test('et invitationslink opretter kontoen og giver adgang til projektet', async
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
await expect(page.getByRole('heading', { name: 'Fælleshuset' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Del' }).click();
|
||||
await projectAction(page, 'Del');
|
||||
await page.getByLabel('Inviter via e-mail').fill('nabo@example.dk');
|
||||
await page.getByRole('button', { name: 'Inviter' }).click();
|
||||
|
||||
@@ -779,7 +798,7 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
|
||||
// controls below only exist once it has.
|
||||
await expect(page.getByRole('heading', { name: 'Fælles' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Del' }).click();
|
||||
await projectAction(page, 'Del');
|
||||
await page.getByLabel('Inviter via e-mail').fill('andreas@example.dk');
|
||||
await page.getByLabel('Rolle').selectOption('viewer');
|
||||
await page.getByRole('button', { name: 'Inviter' }).click();
|
||||
@@ -804,7 +823,7 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
|
||||
// The owner promotes them from the member list. The owner's own row is text,
|
||||
// not a dropdown: ownership is transferred, not granted.
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: /Delt/ }).click();
|
||||
await projectAction(page, /Delt/);
|
||||
await expect(page.getByText('Ejer')).toBeVisible();
|
||||
await page.getByLabel('Rolle for andreas').selectOption('editor');
|
||||
|
||||
@@ -832,7 +851,7 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
|
||||
).toHaveCount(0);
|
||||
|
||||
// The log has recorded all of this since the beginning and nothing showed it.
|
||||
await page.getByRole('button', { name: 'Historik' }).click();
|
||||
await projectAction(page, 'Historik');
|
||||
await expect(page.getByText('oprettede projektet')).toBeVisible();
|
||||
await expect(page.getByText('ændrede rollen for')).toBeVisible();
|
||||
|
||||
@@ -1184,7 +1203,7 @@ test('færdige opgaver kan vises og skjules igen', async ({ page }) => {
|
||||
await expect(page.getByText('male gavlen', { exact: true })).toBeVisible();
|
||||
|
||||
// And it can be looked at again, under its own heading at the bottom.
|
||||
await page.getByRole('button', { name: 'Vis færdige' }).click();
|
||||
await projectAction(page, 'Vis færdige');
|
||||
const done = page.locator('section.done');
|
||||
await expect(done.getByRole('heading', { name: 'Færdige' })).toBeVisible();
|
||||
await expect(done.getByText('rydde op', { exact: true })).toBeVisible();
|
||||
@@ -1196,13 +1215,238 @@ test('færdige opgaver kan vises og skjules igen', async ({ page }) => {
|
||||
await page.reload();
|
||||
await expect(page.locator('section.done').getByText('rydde op', { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Skjul færdige' }).click();
|
||||
await projectAction(page, 'Skjul færdige');
|
||||
await expect(page.locator('section.done')).toHaveCount(0);
|
||||
await expect(page.getByText('male gavlen', { exact: true })).toBeVisible();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A section holds more than one task, and stops being highlighted afterwards.
|
||||
*
|
||||
* Both reported from use, and both the same root cause. TaskList read the dragged
|
||||
* task from its *own* `draggingId`, which is only set when the drag started in
|
||||
* that list — so a task dragged in from the unsectioned rows arrived as null and
|
||||
* the handler returned early. It had already called `stopPropagation`, so the
|
||||
* section around it never got the drop either.
|
||||
*
|
||||
* The result was a section you could only drop into while it was empty, because
|
||||
* an empty one has no rows to aim at. Put one task in, and there was no way to add
|
||||
* a second: it looked exactly like a section that holds one task.
|
||||
*
|
||||
* The same `stopPropagation` left the section's frame lit, since the handler that
|
||||
* clears it never ran.
|
||||
*/
|
||||
test('en sektion kan rumme flere opgaver, og rammen forsvinder bagefter @forms', async ({
|
||||
page
|
||||
}) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||
await sidebar.getByLabel('Projektnavn').fill('Badeværelset');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
await expect(page.getByRole('heading', { name: 'Badeværelset' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: '+ Tilføj sektion' }).click();
|
||||
await page.getByLabel('Ny sektion').fill('Håndværker');
|
||||
await page.getByLabel('Ny sektion').press('Enter');
|
||||
await expect(page.getByRole('heading', { name: 'Håndværker' })).toBeVisible();
|
||||
|
||||
for (const what of ['knager op', 'fuge om vinduet']) {
|
||||
await page.getByLabel('Ny opgave').fill(what);
|
||||
await page.getByLabel('Ny opgave').press('Enter');
|
||||
await expect(page.getByText(what, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
const section = page.locator('section').filter({
|
||||
has: page.getByRole('heading', { name: 'Håndværker' })
|
||||
});
|
||||
const row = (what) => page.locator('.row').filter({ hasText: what });
|
||||
|
||||
// The first goes in while the section is empty — the only drop that used to
|
||||
// work, because there was no row in the way to swallow it.
|
||||
await row('knager op').dragTo(section);
|
||||
await expect(section.getByText('knager op', { exact: true })).toBeVisible();
|
||||
|
||||
// And the second, aimed at the row that is now in there. This is the one that
|
||||
// did nothing at all.
|
||||
await row('fuge om vinduet').dragTo(section.getByText('knager op', { exact: true }));
|
||||
await expect(section.getByText('fuge om vinduet', { exact: true })).toBeVisible();
|
||||
await expect(section.locator('.row')).toHaveCount(2);
|
||||
|
||||
// The frame is a drop target being offered, not a state. Once the drag is over
|
||||
// it has nothing left to say.
|
||||
await expect(section).not.toHaveClass(/\bover\b/);
|
||||
|
||||
// It survives a reload, so this is the server's answer and not the page's.
|
||||
await page.reload();
|
||||
const after = page.locator('section').filter({
|
||||
has: page.getByRole('heading', { name: 'Håndværker' })
|
||||
});
|
||||
await expect(after.locator('.row')).toHaveCount(2);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The project header keeps its name readable.
|
||||
*
|
||||
* It was a flex row of eight controls and a heading, and the heading was the part
|
||||
* that gave way: "GarageRisteriet" rendered as "GarageRist / eriet", broken
|
||||
* mid-word, because `overflow-wrap: anywhere` breaks at the first opportunity
|
||||
* rather than the last necessary one. A name split mid-syllable reads as a
|
||||
* rendering fault, not as a wrap.
|
||||
*
|
||||
* Everything but the view switcher lives behind one button now. This measures the
|
||||
* heading rather than counting controls, because the thing being protected is that
|
||||
* the name is legible — not any particular arrangement of the row.
|
||||
*/
|
||||
test('projektets navn brydes ikke midt i et ord', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||
await sidebar.getByLabel('Projektnavn').fill('GarageRisteriet');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
|
||||
const heading = page.getByRole('heading', { name: 'GarageRisteriet', level: 1 });
|
||||
await expect(heading).toBeVisible();
|
||||
|
||||
// One line. The name is fifteen characters and the row now has room for it,
|
||||
// which is the whole point of moving the rest behind a button.
|
||||
const box = await heading.boundingBox();
|
||||
const lineHeight = await heading.evaluate((el) => parseFloat(getComputedStyle(el).fontSize));
|
||||
expect(box.height, 'overskriften er brudt over flere linjer').toBeLessThan(lineHeight * 2);
|
||||
|
||||
// And the actions are still reachable, just one click further in.
|
||||
await page.getByRole('button', { name: 'Flere handlinger' }).click();
|
||||
for (const item of ['Vis færdige', 'Del', 'Historik', 'Farve på projektet', 'Slet projektet']) {
|
||||
await expect(page.getByRole('menuitem', { name: item, exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
// Escape closes it — a menu you can only leave by pressing its own button again
|
||||
// is one you click twice to get out of.
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByRole('menuitem', { name: 'Historik', exact: true })).toHaveCount(0);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A task can be written straight into a section, two ways.
|
||||
*
|
||||
* The box at the top of the page puts things in the project with no section, which
|
||||
* is right for capturing — but a project has sections because the work belongs
|
||||
* somewhere, and dragging every new task down into the right one is a second
|
||||
* gesture for something you already knew when you typed it.
|
||||
*
|
||||
* The field at the foot of a section passes the section it sits in. The sigil is
|
||||
* for the box at the top, and for Today and Upcoming, where you are not standing
|
||||
* in a section at all.
|
||||
*/
|
||||
test('en opgave kan skrives direkte i en sektion @forms', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||
await sidebar.getByLabel('Projektnavn').fill('Risteriet');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
await expect(page.getByRole('heading', { name: 'Risteriet' })).toBeVisible();
|
||||
|
||||
// A section whose name contains the sigil, because that is the one that would
|
||||
// break: the bare form has to be greedy enough to swallow the whole name.
|
||||
await page.getByRole('button', { name: '+ Tilføj sektion' }).click();
|
||||
await page.getByLabel('Ny sektion').fill('Kunder/Ordrer');
|
||||
await page.getByLabel('Ny sektion').press('Enter');
|
||||
await expect(page.getByRole('heading', { name: 'Kunder/Ordrer' })).toBeVisible();
|
||||
|
||||
const section = page.locator('section').filter({
|
||||
has: page.getByRole('heading', { name: 'Kunder/Ordrer' })
|
||||
});
|
||||
|
||||
// The field at the foot of the section. No sigil — standing there is enough.
|
||||
await section.getByRole('button', { name: '+ Tilføj opgave' }).click();
|
||||
const field = page.getByLabel('Ny opgave i Kunder/Ordrer');
|
||||
await field.fill('Karpenhøj bestilling i morgen p1');
|
||||
await field.press('Enter');
|
||||
|
||||
const landed = section.locator('.row').filter({ hasText: 'Karpenhøj bestilling' });
|
||||
await expect(landed).toBeVisible();
|
||||
// It went through quick add, so the date and the priority were read as well.
|
||||
await expect(landed.getByText('I morgen')).toBeVisible();
|
||||
|
||||
// And the sigil, from the box at the top, which is not standing anywhere.
|
||||
await page.getByLabel('Ny opgave').fill('nye poser /Kunder/Ordrer');
|
||||
await page.getByLabel('Ny opgave').press('Enter');
|
||||
await expect(section.locator('.row').filter({ hasText: 'nye poser' })).toBeVisible();
|
||||
// The sigil is consumed, not left in the title.
|
||||
await expect(section.getByText('/Kunder/Ordrer')).toHaveCount(0);
|
||||
|
||||
await expect(section.locator('.row')).toHaveCount(2);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A task closed by mistake can be got back, two ways.
|
||||
*
|
||||
* Reported from use: "I just closed a task by accident, and there is no undo and no
|
||||
* list of closed tasks." Both halves were true. Completing is one click on a small
|
||||
* circle beside a row you were only reading, and it takes the row off the screen —
|
||||
* so the mistake and the evidence of it leave together.
|
||||
*
|
||||
* The toast covers the mistake you notice at once. Færdige covers the one you
|
||||
* notice tomorrow, and it is a place rather than a toggle because somebody hunting
|
||||
* for a task they closed does not know which view they closed it in.
|
||||
*/
|
||||
test('en opgave lukket ved et uheld kan hentes tilbage', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
await page.getByLabel('Ny opgave').fill('rens tagrenden i dag');
|
||||
await page.getByLabel('Ny opgave').press('Enter');
|
||||
const row = page.locator('.row').filter({ hasText: 'rens tagrenden' });
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
// Closed by mistake.
|
||||
await row.getByRole('button', { name: 'Markér som færdig' }).click();
|
||||
await expect(page.getByText('rens tagrenden', { exact: true })).toBeHidden();
|
||||
|
||||
// The toast names it, so you can tell which one you hit.
|
||||
const toast = page.locator('.toast').filter({ hasText: 'rens tagrenden' });
|
||||
await expect(toast).toBeVisible();
|
||||
await toast.getByRole('button', { name: 'Fortryd' }).click();
|
||||
await expect(page.locator('.row').filter({ hasText: 'rens tagrenden' })).toBeVisible();
|
||||
|
||||
// Now the other half: close it again and walk away from the toast.
|
||||
await page
|
||||
.locator('.row')
|
||||
.filter({ hasText: 'rens tagrenden' })
|
||||
.getByRole('button', { name: 'Markér som færdig' })
|
||||
.click();
|
||||
await expect(page.getByText('rens tagrenden', { exact: true })).toBeHidden();
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByRole('link', { name: 'Færdige' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Færdige', level: 1 })).toBeVisible();
|
||||
|
||||
const closed = page.locator('.row').filter({ hasText: 'rens tagrenden' });
|
||||
await expect(closed).toBeVisible();
|
||||
// Grouped by the day it was closed, which is how anybody would describe it.
|
||||
await expect(page.getByRole('heading', { name: 'I dag', level: 2 })).toBeVisible();
|
||||
|
||||
// And it reopens from here, leaving the list at once.
|
||||
await closed.getByRole('button', { name: 'Genåbn opgave' }).click();
|
||||
await expect(page.locator('.row').filter({ hasText: 'rens tagrenden' })).toHaveCount(0);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The sidebar never scrolls sideways.
|
||||
*
|
||||
|
||||
+2
-1
@@ -267,7 +267,8 @@ export const api = {
|
||||
reopenTask: (id) => post(`/tasks/${id}/reopen`),
|
||||
moveTask: (id, data) => post(`/tasks/${id}/move`, data),
|
||||
|
||||
quickAdd: (text, projectId) => post('/tasks/quick-add', { text, project_id: projectId }),
|
||||
quickAdd: (text, projectId, sectionId) =>
|
||||
post('/tasks/quick-add', { text, project_id: projectId, section_id: sectionId }),
|
||||
quickAddPreview: (text, signal) =>
|
||||
get(`/tasks/quick-add/preview?text=${encodeURIComponent(text)}`, { signal }),
|
||||
|
||||
|
||||
@@ -28,6 +28,23 @@
|
||||
let adding = $state(false);
|
||||
let name = $state('');
|
||||
|
||||
/** Same as the list view's: through quick add, so a date or a p1 still parses. */
|
||||
let addingIn = $state(null);
|
||||
let newInSection = $state('');
|
||||
|
||||
async function addToSection(event, sectionId) {
|
||||
event.preventDefault();
|
||||
const text = newInSection.trim();
|
||||
if (!text) return;
|
||||
newInSection = '';
|
||||
addingIn = null;
|
||||
try {
|
||||
app.upsert(await api.quickAdd(text, project.id, sectionId));
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function addSection(event) {
|
||||
event.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
@@ -147,6 +164,35 @@
|
||||
<p class="empty">{t('view.emptySection')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- At the foot of the column, where the next card would go. A board's
|
||||
columns *are* the sections, so this is the one place where "add a
|
||||
task here" needs no explanation at all. -->
|
||||
{#if canEdit}
|
||||
<!-- `column`, which is what the each binds. The unsectioned column has
|
||||
an empty id, and that is exactly what quick add wants for "in this
|
||||
project, no section". -->
|
||||
{#if addingIn === column.id}
|
||||
<form class="add-task" onsubmit={(e) => addToSection(e, column.id)}>
|
||||
<input
|
||||
bind:value={newInSection}
|
||||
use:focusOnMount
|
||||
placeholder={t('view.addTaskHere')}
|
||||
aria-label={t('view.addTaskIn', { name: column.name })}
|
||||
onblur={() => !newInSection.trim() && (addingIn = null)}
|
||||
onkeydown={(e) => e.key === 'Escape' && (addingIn = null)}
|
||||
/>
|
||||
</form>
|
||||
{:else}
|
||||
<button
|
||||
class="add-task-open"
|
||||
onclick={() => {
|
||||
addingIn = column.id;
|
||||
newInSection = '';
|
||||
}}>{t('view.addTask')}</button
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
@@ -305,6 +351,36 @@
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.add-task-open {
|
||||
align-self: flex-start;
|
||||
padding: var(--s2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-faint);
|
||||
opacity: 0;
|
||||
transition: opacity var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.column:hover .add-task-open,
|
||||
.add-task-open:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.add-task-open:hover {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.add-task input {
|
||||
width: 100%;
|
||||
padding: var(--s2);
|
||||
background: var(--ground);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
font: inherit;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 0;
|
||||
padding: var(--s3) var(--s2);
|
||||
|
||||
@@ -368,6 +368,14 @@
|
||||
{t('nav.delegated')}
|
||||
</a>
|
||||
{/if}
|
||||
<!-- After the three views of what is left, because it is the one that is not
|
||||
about what is left. It earns a permanent place rather than a toggle in
|
||||
each view: somebody looking for a task they closed by mistake does not
|
||||
know which view they closed it in. -->
|
||||
<a href="/faerdige" class:active={current === '/faerdige'} onclick={onnavigate}>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{t('done.title')}
|
||||
</a>
|
||||
{#if app.inbox}
|
||||
{@render projectRow(app.inbox, false)}
|
||||
{/if}
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
* drawn where the row would land.
|
||||
*/
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { api } from '$lib/api.js';
|
||||
import { TASK, startDrag, carries, accept } from '$lib/dnd.js';
|
||||
import { TASK, startDrag, carries, dragged, accept } from '$lib/dnd.js';
|
||||
import TaskRow from './TaskRow.svelte';
|
||||
|
||||
let { tasks, projectId, sectionId = '', canEdit = true } = $props();
|
||||
@@ -33,7 +34,12 @@
|
||||
}
|
||||
|
||||
function onDragOver(event, task) {
|
||||
if (!canEdit || !carries(event, TASK) || !draggingId || draggingId === task.id) return;
|
||||
// `carries` rather than `draggingId`: a task dragged from another list — the
|
||||
// unsectioned rows above, or another section — never called this component's
|
||||
// own dragstart, so its `draggingId` is null. Refusing on that made every
|
||||
// list reject everything that came from outside it, which is the whole point
|
||||
// of dragging between them.
|
||||
if (!canEdit || !carries(event, TASK) || draggingId === task.id) return;
|
||||
accept(event);
|
||||
|
||||
// Which half of the row the pointer is in decides the gap. Comparing against
|
||||
@@ -54,13 +60,22 @@
|
||||
// gesture and for sections with no rows to aim at. A drop that landed on a
|
||||
// row has already said exactly where it goes, so it stops here.
|
||||
event.stopPropagation();
|
||||
const id = draggingId;
|
||||
// From the drag itself, not from `draggingId`, which is only set when the
|
||||
// drag *started* in this list. A task dragged in from the unsectioned rows
|
||||
// or another section left it null, so this returned early — and it had
|
||||
// already called stopPropagation, so the section around it never got the
|
||||
// drop either. The result: a task could only be dropped into a section with
|
||||
// no rows in it to aim at, which made every section look like it could hold
|
||||
// exactly one task.
|
||||
const id = dragged(event, TASK) || draggingId;
|
||||
const below = overBelow;
|
||||
draggingId = null;
|
||||
overId = null;
|
||||
if (!id || !canEdit || id === target.id) return;
|
||||
|
||||
const ordered = [...tasks].sort((a, b) => a.sort_order - b.sort_order);
|
||||
// The dragged task may not be in this list at all — that is the cross-list
|
||||
// case — so `without` is "this list minus it" either way.
|
||||
const without = ordered.filter((t) => t.id !== id);
|
||||
const at = without.findIndex((t) => t.id === target.id);
|
||||
if (at < 0) return;
|
||||
@@ -101,7 +116,7 @@
|
||||
);
|
||||
} catch {
|
||||
app.replace(id, previous);
|
||||
app.toast('Kunne ikke flytte opgaven.');
|
||||
app.toast(t('task.moveFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -587,5 +587,31 @@ export const da = {
|
||||
|
||||
'view.done': 'Færdige',
|
||||
'view.showDone': 'Vis færdige',
|
||||
'view.hideDone': 'Skjul færdige'
|
||||
'view.hideDone': 'Skjul færdige',
|
||||
|
||||
'task.moveFailed': 'Kunne ikke flytte opgaven.',
|
||||
|
||||
'push.updatesOff': 'Der bliver ikke set efter opdateringer.',
|
||||
'push.updateOut': '{version} er ude.',
|
||||
'push.upToDate': 'Ingen nyere version fundet.',
|
||||
|
||||
'view.nothingMoreToday': 'Ikke mere i dag.',
|
||||
'project.roleFor': 'Rolle for {name}',
|
||||
|
||||
'project.more': 'Flere handlinger',
|
||||
'project.removeMember': 'Fjern {name}',
|
||||
|
||||
'nav.closeMenu': 'Luk menu',
|
||||
|
||||
'view.addTask': '+ Tilføj opgave',
|
||||
'view.addTaskHere': 'Skriv en opgave — dato, p1 og @etiket virker her også',
|
||||
'view.addTaskIn': 'Ny opgave i {name}',
|
||||
|
||||
'task.completedUndo': '“{what}” er lukket.',
|
||||
'task.undo': 'Fortryd',
|
||||
|
||||
'done.title': 'Færdige',
|
||||
'done.hint': 'Alt du har lukket, nyeste først. Klik cirklen for at åbne en igen.',
|
||||
'done.empty': 'Du har ikke lukket noget endnu.',
|
||||
'done.unknownDay': 'Uden dato'
|
||||
};
|
||||
|
||||
@@ -582,5 +582,31 @@ export const en = {
|
||||
|
||||
'view.done': 'Done',
|
||||
'view.showDone': 'Show done',
|
||||
'view.hideDone': 'Hide done'
|
||||
'view.hideDone': 'Hide done',
|
||||
|
||||
'task.moveFailed': 'The task could not be moved.',
|
||||
|
||||
'push.updatesOff': 'Updates are not being checked for.',
|
||||
'push.updateOut': '{version} is out.',
|
||||
'push.upToDate': 'No newer version found.',
|
||||
|
||||
'view.nothingMoreToday': 'Nothing more today.',
|
||||
'project.roleFor': 'Role for {name}',
|
||||
|
||||
'project.more': 'More actions',
|
||||
'project.removeMember': 'Remove {name}',
|
||||
|
||||
'nav.closeMenu': 'Close menu',
|
||||
|
||||
'view.addTask': '+ Add a task',
|
||||
'view.addTaskHere': 'Write a task — a date, p1 and @label work here too',
|
||||
'view.addTaskIn': 'New task in {name}',
|
||||
|
||||
'task.completedUndo': '“{what}” is done.',
|
||||
'task.undo': 'Undo',
|
||||
|
||||
'done.title': 'Done',
|
||||
'done.hint': 'Everything you have closed, newest first. Click the circle to reopen one.',
|
||||
'done.empty': 'You have not closed anything yet.',
|
||||
'done.unknownDay': 'No date'
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
import { api, ApiError, humanMessage } from './api.js';
|
||||
import { i18n } from './i18n.svelte.js';
|
||||
import { i18n, t } from './i18n.svelte.js';
|
||||
|
||||
class AppState {
|
||||
user = $state(null);
|
||||
@@ -278,8 +278,36 @@ class AppState {
|
||||
* leaves — which is the single most-repeated action in the app, and the one
|
||||
* place latency would be felt all day.
|
||||
*/
|
||||
/**
|
||||
* Closes a task, and offers to put it back.
|
||||
*
|
||||
* Completing is one click on a small circle next to a row you were only
|
||||
* reading, and it takes the row off the screen — so the mistake and the
|
||||
* evidence of it leave together. Somebody who clicks the wrong one is left with
|
||||
* no name to search for and no list to find it in.
|
||||
*
|
||||
* The undo is offered where the mistake happened rather than filed somewhere to
|
||||
* be looked up. It is the same call `reopen` makes; the only new thing is that
|
||||
* the interface says it is available.
|
||||
*
|
||||
* A recurring task is deliberately not offered one. Completing it moves it to
|
||||
* its next date rather than closing it, so there is nothing to undo — and an
|
||||
* "undo" that silently rolled the date back would be a different act from the
|
||||
* one it claims to reverse.
|
||||
*/
|
||||
async complete(id) {
|
||||
const task = this.get(id);
|
||||
const repeats = Boolean(task?.recurrence_rule);
|
||||
const what = task?.content ?? '';
|
||||
|
||||
await this.#optimistic(id, { completed: true }, () => api.completeTask(id));
|
||||
|
||||
if (!repeats) {
|
||||
this.toast(t('task.completedUndo', { what: truncate(what) }), {
|
||||
action: t('task.undo'),
|
||||
onaction: () => this.reopen(id)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async reopen(id) {
|
||||
@@ -569,12 +597,22 @@ class AppState {
|
||||
|
||||
// --- toasts -------------------------------------------------------------------
|
||||
|
||||
toast(message) {
|
||||
/**
|
||||
* Says something, and optionally offers one thing to do about it.
|
||||
*
|
||||
* An action gets longer on screen than a plain message: five seconds is enough
|
||||
* to read "could not save", and not enough to notice you closed the wrong task,
|
||||
* decide, and reach the mouse.
|
||||
*/
|
||||
toast(message, { action, onaction } = {}) {
|
||||
const id = Math.random().toString(36).slice(2);
|
||||
this.toasts.push({ id, message });
|
||||
setTimeout(() => {
|
||||
this.toasts = this.toasts.filter((t) => t.id !== id);
|
||||
}, 5000);
|
||||
this.toasts.push({ id, message, action, onaction });
|
||||
setTimeout(
|
||||
() => {
|
||||
this.toasts = this.toasts.filter((t) => t.id !== id);
|
||||
},
|
||||
action ? 12000 : 5000
|
||||
);
|
||||
}
|
||||
|
||||
dismissToast(id) {
|
||||
@@ -582,6 +620,12 @@ class AppState {
|
||||
}
|
||||
}
|
||||
|
||||
/** Enough of a task's title to recognise it, without a toast the width of the page. */
|
||||
function truncate(text, max = 40) {
|
||||
const trimmed = text.trim();
|
||||
return trimmed.length > max ? trimmed.slice(0, max - 1).trimEnd() + '…' : trimmed;
|
||||
}
|
||||
|
||||
export const app = new AppState();
|
||||
|
||||
/**
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
<button
|
||||
class="scrim"
|
||||
onclick={() => (sidebarOpen = false)}
|
||||
aria-label="Luk menu"
|
||||
aria-label={t('nav.closeMenu')}
|
||||
tabindex="-1"
|
||||
></button>
|
||||
{/if}
|
||||
@@ -160,9 +160,25 @@
|
||||
had succeeded. Nothing else earns an interruption. -->
|
||||
<div class="toasts" role="status" aria-live="polite">
|
||||
{#each app.toasts as toast (toast.id)}
|
||||
<button class="toast" onclick={() => app.dismissToast(toast.id)}>
|
||||
{toast.message}
|
||||
</button>
|
||||
<div class="toast">
|
||||
<!-- The message dismisses, the action acts. They were one button before,
|
||||
which is fine while the only thing a toast says is "that failed" —
|
||||
and wrong the moment it offers to undo something, because then a
|
||||
click meaning "yes, put it back" and a click meaning "go away" land
|
||||
in the same place. -->
|
||||
<button class="body" onclick={() => app.dismissToast(toast.id)}>
|
||||
{toast.message}
|
||||
</button>
|
||||
{#if toast.action}
|
||||
<button
|
||||
class="action"
|
||||
onclick={() => {
|
||||
app.dismissToast(toast.id);
|
||||
toast.onaction?.();
|
||||
}}>{toast.action}</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -304,6 +320,29 @@
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.toast .body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* Set apart, because it is the one thing in a toast that does something rather
|
||||
than says something. */
|
||||
.toast .action {
|
||||
flex: none;
|
||||
padding: var(--s1) var(--s2);
|
||||
margin: calc(var(--s1) * -1) calc(var(--s1) * -1) calc(var(--s1) * -1) 0;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 560;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.toast .action:hover {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.toasts {
|
||||
position: fixed;
|
||||
bottom: var(--s4);
|
||||
@@ -317,8 +356,14 @@
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--line-strong);
|
||||
/* The red edge said "something went wrong", which was true while that was
|
||||
all a toast could say. An undo is not a failure — it is the app offering
|
||||
to put something back — so the edge follows the message. */
|
||||
border-left: 2px solid var(--danger);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-lg);
|
||||
@@ -329,6 +374,10 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.toast:has(.action) {
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Below the tablet breakpoint the sidebar becomes a drawer. The layout is
|
||||
mobile-first in behaviour even though the rules read desktop-first: the
|
||||
drawer is the exception, and writing it as one keeps it in a single block. */
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
like finishing, not like something is missing. -->
|
||||
<p class="clear">
|
||||
<span class="rune" aria-hidden="true">ᚹ</span>
|
||||
Ikke mere i dag.
|
||||
{t('view.nothingMoreToday')}
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<script>
|
||||
/**
|
||||
* Færdige: everything that has been closed, newest first.
|
||||
*
|
||||
* Completing a task is one click on a small circle beside a row you were only
|
||||
* reading, and it takes the row off the screen — so the mistake and the evidence
|
||||
* of it leave together. Somebody who clicked the wrong one had no name to search
|
||||
* for and nowhere to look: the project page can show its own closed tasks, but
|
||||
* only if you already know which project it was in.
|
||||
*
|
||||
* The toast that appears on completing offers an undo, which covers the mistake
|
||||
* you notice at once. This covers the one you notice tomorrow.
|
||||
*
|
||||
* Newest first, decided by the server. A record of what has been finished is
|
||||
* read backwards from now, not by a due date the task no longer has.
|
||||
*/
|
||||
import { api } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { t, tag } from '$lib/i18n.svelte.js';
|
||||
import TaskRow from '$lib/components/TaskRow.svelte';
|
||||
|
||||
let loaded = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
app.loadTasks({ completed: 'only', limit: 200 }).then(() => (loaded = true));
|
||||
});
|
||||
|
||||
// Read back out of the store, so a task reopened from here leaves the list at
|
||||
// once rather than on the next load — which is the whole reason to be here.
|
||||
let done = $derived(app.tasks.filter((task) => task.completed));
|
||||
|
||||
/**
|
||||
* Grouped by the day it was closed.
|
||||
*
|
||||
* A flat list of two hundred rows says when nothing. "I dag" and "I går" are
|
||||
* how anybody looking for something they just closed would describe it.
|
||||
*/
|
||||
let days = $derived.by(() => {
|
||||
const groups = new Map();
|
||||
for (const task of done) {
|
||||
const key = (task.completed_at ?? '').slice(0, 10);
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(task);
|
||||
}
|
||||
return [...groups.entries()].map(([date, tasks]) => ({ date, tasks }));
|
||||
});
|
||||
|
||||
function heading(date) {
|
||||
if (!date) return t('done.unknownDay');
|
||||
const day = new Date(date + 'T00:00:00');
|
||||
const today = new Date(new Date().toDateString());
|
||||
const diff = Math.round((day - today) / 86400000);
|
||||
if (diff === 0) return t('task.today');
|
||||
if (diff === -1) return t('task.yesterday');
|
||||
return day.toLocaleDateString(tag(), { weekday: 'long', day: 'numeric', month: 'long' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{t('done.title')} — verdande</title></svelte:head>
|
||||
|
||||
<div class="view">
|
||||
<header>
|
||||
<h1>{t('done.title')}</h1>
|
||||
<p>{t('done.hint')}</p>
|
||||
</header>
|
||||
|
||||
{#each days as day (day.date)}
|
||||
<section>
|
||||
<h2>{heading(day.date)}</h2>
|
||||
{#each day.tasks as task (task.id)}
|
||||
<TaskRow {task} />
|
||||
{/each}
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
{#if loaded && !done.length}
|
||||
<p class="empty">{t('done.empty')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.view {
|
||||
max-width: var(--content-max);
|
||||
margin: 0 auto;
|
||||
padding: var(--s6) var(--s4) var(--s8);
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: var(--s5);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
|
||||
header p {
|
||||
margin: var(--s1) 0 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: var(--s5);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 560;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--ink-faint);
|
||||
padding-bottom: var(--s2);
|
||||
border-bottom: 1px solid var(--line);
|
||||
margin-bottom: var(--s2);
|
||||
}
|
||||
|
||||
.empty {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
</style>
|
||||
@@ -174,11 +174,11 @@
|
||||
<p class="hint">
|
||||
{t('push.running')} <span class="mono">{version.current}</span>.
|
||||
{#if version.disabled}
|
||||
Der bliver ikke set efter opdateringer.
|
||||
{t('push.updatesOff')}
|
||||
{:else if version.update_available}
|
||||
<strong>{version.latest} er ude.</strong>
|
||||
<strong>{t('push.updateOut', { version: version.latest })}</strong>
|
||||
{:else if version.checked_at}
|
||||
Ingen nyere version fundet.
|
||||
{t('push.upToDate')}
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
|
||||
@@ -104,8 +104,8 @@
|
||||
{#if fresh}
|
||||
<div class="fresh">
|
||||
<p class="hint">
|
||||
<strong>{t('tokens.copyNow')}</strong> Den vises kun denne ene gang — serveren har
|
||||
kun dens hash.
|
||||
<strong>{t('tokens.copyNow')}</strong>
|
||||
{t('tokens.onceOnly')}
|
||||
</p>
|
||||
<p class="mono value">{fresh.token}</p>
|
||||
<div class="row">
|
||||
|
||||
@@ -140,6 +140,24 @@
|
||||
|
||||
let editing = $state(false);
|
||||
let choosingColor = $state(false);
|
||||
let showMenu = $state(false);
|
||||
|
||||
// Closes on a click anywhere else and on Escape. A menu that only closes by
|
||||
// pressing its own button again is one you end up clicking twice to leave.
|
||||
$effect(() => {
|
||||
if (!showMenu) return;
|
||||
const away = (event) => {
|
||||
if (!event.target.closest?.('.more')) showMenu = false;
|
||||
};
|
||||
const escape = (event) => event.key === 'Escape' && (showMenu = false);
|
||||
// Capture, so a click that its own handler stops still closes the menu.
|
||||
window.addEventListener('click', away, true);
|
||||
window.addEventListener('keydown', escape);
|
||||
return () => {
|
||||
window.removeEventListener('click', away, true);
|
||||
window.removeEventListener('keydown', escape);
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* The colour is written to the store as well as here: the sidebar reads its own
|
||||
@@ -197,6 +215,24 @@
|
||||
*/
|
||||
let overSection = $state(null);
|
||||
|
||||
/**
|
||||
* Clears the highlight when the drag ends, however it ends.
|
||||
*
|
||||
* `dragleave` and the section's own `ondrop` are not enough. A drop on a *row*
|
||||
* inside a section is handled by TaskList, which stops propagation — so the
|
||||
* section never sees the drop and its frame stayed lit afterwards. A drag
|
||||
* abandoned with Escape has the same shape: no drop at all, and the frame kept
|
||||
* standing until the next one.
|
||||
*
|
||||
* `dragend` fires exactly once per drag, on the source, and bubbles to the
|
||||
* window — so it is the one place that is true for every way a drag can finish.
|
||||
*/
|
||||
$effect(() => {
|
||||
const clear = () => (overSection = null);
|
||||
window.addEventListener('dragend', clear);
|
||||
return () => window.removeEventListener('dragend', clear);
|
||||
});
|
||||
|
||||
function onSectionDragOver(event, sectionId) {
|
||||
if (!canEdit || !carries(event, TASK)) return;
|
||||
accept(event);
|
||||
@@ -241,6 +277,36 @@
|
||||
let renamingSection = $state(null);
|
||||
let sectionName = $state('');
|
||||
|
||||
/**
|
||||
* A task written straight into a section.
|
||||
*
|
||||
* The quick-add box at the top of the page puts things in the project with no
|
||||
* section, which is right for "capture it now" — but a project with sections has
|
||||
* them because the work belongs somewhere, and dragging every new task down into
|
||||
* the right one is a second gesture for something you already knew when you
|
||||
* typed it.
|
||||
*
|
||||
* It goes through quick add rather than createTask, so a line written here still
|
||||
* parses its dates, priorities and labels. The section is passed rather than
|
||||
* written, so `/Produktion` is not needed when you are already standing in it.
|
||||
*/
|
||||
let addingIn = $state(null);
|
||||
let newInSection = $state('');
|
||||
|
||||
async function addToSection(event, sectionId) {
|
||||
event.preventDefault();
|
||||
const text = newInSection.trim();
|
||||
if (!text) return;
|
||||
newInSection = '';
|
||||
addingIn = null;
|
||||
try {
|
||||
const created = await api.quickAdd(text, project.id, sectionId);
|
||||
app.upsert(created);
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function addSection(event) {
|
||||
event.preventDefault();
|
||||
const name = sectionName.trim();
|
||||
@@ -370,24 +436,6 @@
|
||||
{/if}
|
||||
</h1>
|
||||
{/if}
|
||||
{#if isOwner && !project.is_inbox}
|
||||
<button
|
||||
class="color"
|
||||
style="background: {colorVar(project.color)}"
|
||||
onclick={() => (choosingColor = !choosingColor)}
|
||||
aria-expanded={choosingColor}
|
||||
aria-label={t('project.color')}
|
||||
title={t('project.color')}
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="ghost"
|
||||
onclick={() => completedView.toggle()}
|
||||
aria-pressed={completedView.shown}
|
||||
>
|
||||
{completedView.shown ? t('view.hideDone') : t('view.showDone')}
|
||||
</button>
|
||||
<div class="views" role="group" aria-label={t('view.mode')}>
|
||||
{#each [['list', t('view.list')], ['board', t('view.board')], ['calendar', t('view.calendar')]] as [value, label]}
|
||||
<button
|
||||
@@ -398,25 +446,94 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if !project.is_inbox}
|
||||
<button class="share" onclick={() => (showShare = !showShare)}>
|
||||
{project.shared ? t('project.sharedWith', { n: project.member_count }) : t('project.share')}
|
||||
</button>
|
||||
{/if}
|
||||
<!-- Everything else lives behind one button.
|
||||
The row had eight controls in it — title, colour, show-done, three view
|
||||
buttons, share, history, delete — and a project name is not a short
|
||||
word. They fitted only on a wide window, and past that the heading was
|
||||
the thing that gave way, which is the one part of the row that has to
|
||||
be readable.
|
||||
|
||||
{#if !project.is_inbox}
|
||||
<button class="share" onclick={() => (showLog = !showLog)} aria-expanded={showLog}>
|
||||
{t('project.history')}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if isOwner && !project.is_inbox}
|
||||
<button class="remove" onclick={remove} aria-label={t('project.delete')}>
|
||||
What stays out is what you use while working: which view you are in.
|
||||
What moves in is what you do to a project rather than in it, and most
|
||||
of it once ever. -->
|
||||
<div class="more">
|
||||
<button
|
||||
class="more-toggle"
|
||||
onclick={() => (showMenu = !showMenu)}
|
||||
aria-expanded={showMenu}
|
||||
aria-haspopup="menu"
|
||||
aria-label={t('project.more')}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 7h16M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2M6 7l1 13h10l1-13" />
|
||||
<circle cx="5" cy="12" r="1.6" />
|
||||
<circle cx="12" cy="12" r="1.6" />
|
||||
<circle cx="19" cy="12" r="1.6" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if showMenu}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div class="menu" role="menu">
|
||||
<button
|
||||
role="menuitem"
|
||||
onclick={() => {
|
||||
completedView.toggle();
|
||||
showMenu = false;
|
||||
}}
|
||||
>
|
||||
{completedView.shown ? t('view.hideDone') : t('view.showDone')}
|
||||
</button>
|
||||
|
||||
{#if !project.is_inbox}
|
||||
<button
|
||||
role="menuitem"
|
||||
onclick={() => {
|
||||
showShare = !showShare;
|
||||
showMenu = false;
|
||||
}}
|
||||
>
|
||||
{project.shared
|
||||
? t('project.sharedWith', { n: project.member_count })
|
||||
: t('project.share')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
role="menuitem"
|
||||
onclick={() => {
|
||||
showLog = !showLog;
|
||||
showMenu = false;
|
||||
}}
|
||||
>
|
||||
{t('project.history')}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if isOwner && !project.is_inbox}
|
||||
<button
|
||||
role="menuitem"
|
||||
onclick={() => {
|
||||
choosingColor = !choosingColor;
|
||||
showMenu = false;
|
||||
}}
|
||||
>
|
||||
<span class="swatch-dot" style="background: {colorVar(project.color)}"></span>
|
||||
{t('project.color')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
role="menuitem"
|
||||
class="danger"
|
||||
onclick={() => {
|
||||
showMenu = false;
|
||||
remove();
|
||||
}}
|
||||
>
|
||||
{t('project.delete')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if showLog}
|
||||
@@ -427,7 +544,7 @@
|
||||
<!-- Empty when the account has been deleted: the record of what was
|
||||
done outlives whoever did it, so the row stays and the name goes. -->
|
||||
<span class="who" class:gone={!entry.user_name}
|
||||
>{entry.user_name || 'En slettet konto'}</span
|
||||
>{entry.user_name || t('history.deletedAccount')}</span
|
||||
>
|
||||
<span class="what">
|
||||
{eventName(entry.event)}{#if eventDetail(entry)}
|
||||
@@ -475,7 +592,7 @@
|
||||
<select
|
||||
class="role-picker"
|
||||
value={member.role}
|
||||
aria-label="Rolle for {member.name}"
|
||||
aria-label={t('project.roleFor', { name: member.name })}
|
||||
onchange={(e) => setMemberRole(member, e.currentTarget.value)}
|
||||
>
|
||||
<option value="editor">{ROLES.editor}</option>
|
||||
@@ -491,7 +608,7 @@
|
||||
await api.removeMember(project.id, member.user_id);
|
||||
members = (await api.listMembers(project.id)).members;
|
||||
}}
|
||||
aria-label="Fjern {member.name}">×</button
|
||||
aria-label={t('project.removeMember', { name: member.name })}>×</button
|
||||
>
|
||||
{/if}
|
||||
</li>
|
||||
@@ -597,6 +714,29 @@
|
||||
{#if !tasks.length}
|
||||
<p class="empty">{t('view.emptySection')}</p>
|
||||
{/if}
|
||||
|
||||
{#if canEdit}
|
||||
{#if addingIn === section.id}
|
||||
<form class="add-task" onsubmit={(e) => addToSection(e, section.id)}>
|
||||
<input
|
||||
bind:value={newInSection}
|
||||
use:focusOnMount
|
||||
placeholder={t('view.addTaskHere')}
|
||||
aria-label={t('view.addTaskIn', { name: section.name })}
|
||||
onblur={() => !newInSection.trim() && (addingIn = null)}
|
||||
onkeydown={(e) => e.key === 'Escape' && (addingIn = null)}
|
||||
/>
|
||||
</form>
|
||||
{:else}
|
||||
<button
|
||||
class="add-task-open"
|
||||
onclick={() => {
|
||||
addingIn = section.id;
|
||||
newInSection = '';
|
||||
}}>{t('view.addTask')}</button
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
@@ -664,22 +804,6 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* A quiet control: it changes what you are looking at, not what is there. It
|
||||
sits beside the view switcher because it is the same kind of decision. */
|
||||
.ghost {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-faint);
|
||||
padding: var(--s1) var(--s2);
|
||||
border-radius: var(--radius);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.ghost:hover,
|
||||
.ghost[aria-pressed='true'] {
|
||||
color: var(--ink-muted);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
/* Recessed, because it is a record rather than a plan. The rows keep their own
|
||||
completed styling; this is about the section around them. */
|
||||
.done {
|
||||
@@ -693,10 +817,6 @@
|
||||
}
|
||||
|
||||
/* A board and a month grid need the room; a list is easier to read narrow. */
|
||||
.view:has(.board),
|
||||
.view:has(.calendar) {
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
/* Wraps, and the title keeps a floor.
|
||||
*
|
||||
@@ -716,11 +836,15 @@
|
||||
|
||||
h1 {
|
||||
font-size: var(--text-2xl);
|
||||
/* 8ch rather than 0: the basis is what stops flex shrinking it past the
|
||||
point where the wrapping below is the better answer. */
|
||||
flex: 1 1 8ch;
|
||||
min-width: 8ch;
|
||||
overflow-wrap: anywhere;
|
||||
/* Takes the row, and lets the controls keep their own width. With the rest
|
||||
of them behind one button there is room for a real project name. */
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
/* `break-word`, not `anywhere`. `anywhere` breaks inside a word at the first
|
||||
opportunity, so "GarageRisteriet" came out as "GarageRist / eriet" the
|
||||
moment the row was tight — a name split mid-syllable reads as a rendering
|
||||
fault. This only breaks a word that cannot fit on a line of its own. */
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* The heading's button carries no button-ness: it is the heading, and the only
|
||||
@@ -761,6 +885,83 @@
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* --- the overflow menu ------------------------------------------------------ */
|
||||
|
||||
.more {
|
||||
position: relative;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.more-toggle {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--radius);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.more-toggle:hover,
|
||||
.more-toggle[aria-expanded='true'] {
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.more-toggle svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
/* Right-aligned, because the button is at the right end of the row and a menu
|
||||
that opened leftward from there would hang off the page. */
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--s1));
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
min-width: 190px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--s1);
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.menu button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: var(--s2) var(--s3);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu button:hover {
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
.menu button.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.menu button.danger:hover {
|
||||
background: var(--danger-sunken);
|
||||
}
|
||||
|
||||
.swatch-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: var(--radius-full);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.remove {
|
||||
flex: none;
|
||||
width: 30px;
|
||||
@@ -779,34 +980,7 @@
|
||||
background: var(--danger-sunken);
|
||||
}
|
||||
|
||||
.remove svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.6;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* A circle in the project's own colour, which is both the control and its own
|
||||
preview: the thing you are about to change is what you press. */
|
||||
.color {
|
||||
flex: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: var(--radius-full);
|
||||
box-shadow: 0 0 0 2px var(--ground);
|
||||
transition: box-shadow var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.color:hover,
|
||||
.color:focus-visible {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--ground),
|
||||
0 0 0 3px var(--line-strong);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.swatches {
|
||||
display: flex;
|
||||
@@ -869,20 +1043,7 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.share {
|
||||
flex: none;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-muted);
|
||||
padding: var(--s1) var(--s3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-full);
|
||||
transition: border-color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.share:hover {
|
||||
border-color: var(--line-strong);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
@@ -1159,6 +1320,38 @@
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Quiet until wanted. A section that is not being added to should read as a
|
||||
heading and its rows, not as a form. */
|
||||
.add-task-open {
|
||||
align-self: flex-start;
|
||||
padding: var(--s2) 0 var(--s2) var(--s2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-faint);
|
||||
opacity: 0;
|
||||
transition: opacity var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
section:hover .add-task-open,
|
||||
.add-task-open:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.add-task-open:hover {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.add-task input {
|
||||
width: 100%;
|
||||
padding: var(--s2);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--accent);
|
||||
font: inherit;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.add-section {
|
||||
margin-top: var(--s5);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user