diff --git a/cmd/alphone/seed.go b/cmd/alphone/seed.go
index adc0482..8d7cec3 100644
--- a/cmd/alphone/seed.go
+++ b/cmd/alphone/seed.go
@@ -52,15 +52,12 @@ func seed(ctx context.Context, getenv func(string) string, stdout io.Writer) err
return err
}
resolver := contact.NewResolver(postgres.NewContactStore(pool))
- ada, err := resolver.Resolve(ctx, "email", "ada@example.com", "Ada Lovelace")
+ contacts, err := seedContacts(ctx, resolver)
if err != nil {
- return fmt.Errorf("seed contact: %w", err)
- }
- if _, err := resolver.Resolve(ctx, "email", "maria.perez@example.com", "Maria Perez"); err != nil {
- return fmt.Errorf("seed contact: %w", err)
+ return err
}
tasks := postgres.NewTaskStore(pool)
- if err := seedTasks(ctx, tasks, authkitpg.NewUserStore(pool), ada.ID); err != nil {
+ if err := seedTasks(ctx, tasks, authkitpg.NewUserStore(pool), contacts); err != nil {
return err
}
if err := seedPlugins(ctx, databaseURL, getenv, resolver); err != nil {
@@ -115,6 +112,33 @@ func reportLogins(stdout io.Writer, created map[string]bool) {
}
}
+// demoContact is one contact the seeder ensures, found by its email.
+type demoContact struct {
+ email string
+ name string
+}
+
+// demoContacts names every contact the seeder ensures, in creation order.
+func demoContacts() []demoContact {
+ return []demoContact{
+ {email: "ada@example.com", name: "Ada Lovelace"},
+ {email: "maria.perez@example.com", name: "Maria Perez"},
+ }
+}
+
+// seedContacts stores the demo contacts and returns each one's id by email.
+func seedContacts(ctx context.Context, resolver *contact.Resolver) (map[string]uuid.UUID, error) {
+ ids := make(map[string]uuid.UUID, len(demoContacts()))
+ for _, demo := range demoContacts() {
+ stored, err := resolver.Resolve(ctx, "email", demo.email, demo.name)
+ if err != nil {
+ return nil, fmt.Errorf("seed contact: %w", err)
+ }
+ ids[demo.email] = stored.ID
+ }
+ return ids, nil
+}
+
// demoTask is one scripted task of the demo data set.
type demoTask struct {
id string
@@ -122,7 +146,7 @@ type demoTask struct {
offset int
priority int
done bool
- linked bool
+ contact string
origin string
member bool
}
@@ -131,7 +155,7 @@ type demoTask struct {
func demoTasks() []demoTask {
return []demoTask{
{id: "0198d000-0000-7000-8000-000000000001", title: "Chase the overdue invoice", offset: -1},
- {id: "0198d000-0000-7000-8000-000000000002", title: "Call Ada about the renewal", linked: true},
+ {id: "0198d000-0000-7000-8000-000000000002", title: "Call Ada about the renewal", contact: "ada@example.com"},
{id: "0198d000-0000-7000-8000-000000000003", title: "Approve the new pricing", priority: 1},
{id: "0198d000-0000-7000-8000-000000000004", title: "File the delivery notes", done: true},
{
@@ -145,6 +169,12 @@ func demoTasks() []demoTask {
title: "Draft the welcome email",
member: true,
},
+ {
+ id: "0198d000-0000-7000-8000-000000000007",
+ title: "Follow up with Maria on the offer",
+ offset: 3,
+ contact: "maria.perez@example.com",
+ },
}
}
@@ -157,7 +187,7 @@ func seedTasks(
ctx context.Context,
store *postgres.TaskStore,
users gouncer.Store,
- contactID uuid.UUID,
+ contacts map[string]uuid.UUID,
) error {
admin, err := users.UserByEmail(ctx, seedAdminEmail)
if err != nil {
@@ -179,7 +209,7 @@ func seedTasks(
} else if !errors.Is(err, task.ErrNotFound) {
return fmt.Errorf("seed task lookup: %w", err)
}
- built, err := buildDemoTask(scripted, id, today, assigneeID, contactID)
+ built, err := buildDemoTask(scripted, id, today, assigneeID, contacts[scripted.contact])
if err != nil {
return err
}
@@ -203,7 +233,7 @@ func buildDemoTask(
Priority: scripted.priority,
AssigneeID: assigneeID,
}
- if scripted.linked {
+ if scripted.contact != "" {
in.ContactID = contactID
}
if scripted.origin != "" {
diff --git a/cmd/alphone/seed_test.go b/cmd/alphone/seed_test.go
index 1f41fdb..ca453f5 100644
--- a/cmd/alphone/seed_test.go
+++ b/cmd/alphone/seed_test.go
@@ -87,7 +87,7 @@ func TestSeedPopulatesTheDemoData(t *testing.T) {
if !gouncer.VerifyPassword(admin.PasswordHash, "password1234") {
t.Error("stored password hash does not verify against the demo password")
}
- if got, want := demoCounts(t, pool), [7]int{7, 6, 3, 8, 1, 1, 6}; got != want {
+ if got, want := demoCounts(t, pool), [7]int{7, 7, 3, 8, 1, 1, 6}; got != want {
t.Errorf("demo counts = %v, want %v", got, want)
}
var adas int
@@ -262,6 +262,29 @@ func TestSeedStoresADayOfTasks(t *testing.T) {
}
}
+func TestSeedLinksATaskDueInThreeDaysToTheHistoryContact(t *testing.T) {
+ t.Parallel()
+
+ databaseURL := testDatabaseURL(t)
+ getenv := testGetenv(map[string]string{"ALPHONE_DATABASE_URL": databaseURL})
+ if err := seed(t.Context(), getenv, &strings.Builder{}); err != nil {
+ t.Fatalf("seed() error = %v, want nil", err)
+ }
+ pool := testPool(t, databaseURL)
+
+ var dueOn string
+ err := pool.QueryRow(t.Context(), `
+ SELECT to_char(t.due_on, 'YYYY-MM-DD')
+ FROM core.tasks t
+ JOIN core.contacts c ON c.id = t.contact_id
+ WHERE c.name = 'Maria Perez'`).Scan(&dueOn)
+
+ want := time.Now().UTC().AddDate(0, 0, 3).Format(time.DateOnly)
+ if err != nil || dueOn != want {
+ t.Errorf("the history contact's task is due %q (err %v), want %q", dueOn, err, want)
+ }
+}
+
func TestSeedRaisesOneTaskAboveTheRest(t *testing.T) {
t.Parallel()
@@ -307,7 +330,7 @@ func TestSeedTasksReportsAdminLookupFailure(t *testing.T) {
users.LookupErr = errors.New("store down")
store := postgres.NewTaskStore(testPool(t, testDatabaseURL(t)))
- err := seedTasks(t.Context(), store, users, uuid.Must(uuid.NewV7()))
+ err := seedTasks(t.Context(), store, users, nil)
if err == nil {
t.Fatal("seedTasks() error = nil, want an admin lookup failure")
@@ -324,7 +347,7 @@ func TestSeedTasksReportsLookupFailure(t *testing.T) {
store := postgres.NewTaskStore(pool)
pool.Close()
- err := seedTasks(t.Context(), store, users, uuid.Must(uuid.NewV7()))
+ err := seedTasks(t.Context(), store, users, nil)
if err == nil {
t.Fatal("seedTasks() error = nil, want a lookup failure")
@@ -338,7 +361,7 @@ func TestSeedTasksReportsAColleagueLookupFailure(t *testing.T) {
users.AddUser(t, seedAdminEmail, seedAdminName, seedAdminPassword)
store := postgres.NewTaskStore(testPool(t, testDatabaseURL(t)))
- err := seedTasks(t.Context(), store, users, uuid.Must(uuid.NewV7()))
+ err := seedTasks(t.Context(), store, users, nil)
if err == nil {
t.Fatal("seedTasks() error = nil, want the missing colleague reported")
@@ -356,7 +379,7 @@ func TestSeedTasksReportsIDGenerationFailure(t *testing.T) {
uuid.SetRand(failingReader{})
defer uuid.SetRand(nil)
- err := seedTasks(t.Context(), store, users, uuid.Nil)
+ err := seedTasks(t.Context(), store, users, nil)
if !errors.Is(err, errEntropy) {
t.Fatalf("seedTasks() error = %v, want the entropy failure in its chain", err)
@@ -378,7 +401,7 @@ func TestSeedIsIdempotentAcrossRuns(t *testing.T) {
}
pool := testPool(t, databaseURL)
- if got, want := demoCounts(t, pool), [7]int{7, 6, 3, 8, 1, 1, 6}; got != want {
+ if got, want := demoCounts(t, pool), [7]int{7, 7, 3, 8, 1, 1, 6}; got != want {
t.Errorf("demo counts after two runs = %v, want %v", got, want)
}
if !strings.Contains(second.String(), "admin@example.com already exists") {
diff --git a/docs/src/content/docs/extending/screens.md b/docs/src/content/docs/extending/screens.md
index 0a11f5b..2e52d83 100644
--- a/docs/src/content/docs/extending/screens.md
+++ b/docs/src/content/docs/extending/screens.md
@@ -43,6 +43,14 @@ export function InvoicesScreen() {
`subtitle` and `actions` are optional. The page spans the full canvas
width, so a screen with one action and a screen with four still line up.
+Pass `aside` to show a second column beside the content, for details that
+sit next to the main work. On a wide page it starts 320px wide. On a narrow
+page it moves under the content, and an aside that renders nothing takes no
+room.
+
+A plugin's contact panel always renders in the contact page's aside, so
+design it for a 320px column.
+
Put navigation and creation controls in `actions`. A form's submit
button belongs at the bottom of the form, not in the header.
diff --git a/docs/src/content/docs/guides/contacts.md b/docs/src/content/docs/guides/contacts.md
index 121bede..e1826fd 100644
--- a/docs/src/content/docs/guides/contacts.md
+++ b/docs/src/content/docs/guides/contacts.md
@@ -44,6 +44,13 @@ A contact's detail page lists its identities: the per-channel handles
that link conversations to the contact, such as a WhatsApp phone number.
Identities are created automatically by channels and cannot be edited.
+## The contact page
+
+A contact's page has two columns. The left one holds the name, the
+identities and the tasks, which is where the work happens. The right one
+holds the date the contact was created and the custom fields. On a narrow
+screen the right column moves under the tasks.
+
## Storing more about a contact
A contact holds only a name and its identities out of the box. To store an
diff --git a/docs/src/content/docs/guides/fields.md b/docs/src/content/docs/guides/fields.md
index e646815..261786e 100644
--- a/docs/src/content/docs/guides/fields.md
+++ b/docs/src/content/docs/guides/fields.md
@@ -36,8 +36,9 @@ filled in.
## Fill a field in
-Open a contact. Below the tasks you will find a **Fields** section with one
-input per field you created. Type, then press **Save fields**.
+Open a contact. The **Fields** section sits beside the tasks, or under them
+on a narrow screen, with one input per field you created. Type, then press
+**Save fields**.
A Long text field is a box several lines tall. Press Enter to start a new
line. The text keeps its line breaks when you save.
diff --git a/docs/src/content/docs/guides/tasks.md b/docs/src/content/docs/guides/tasks.md
index fe47b90..76b17af 100644
--- a/docs/src/content/docs/guides/tasks.md
+++ b/docs/src/content/docs/guides/tasks.md
@@ -5,7 +5,7 @@ description: How the daily task list works, from quick capture to rescheduling w
Tasks are the work you owe someone. AlphOne opens on the day's list, and
that list is the main screen of the product: work it top to bottom and
-push forward whatever did not happen.
+postpone whatever did not happen.
## The day
@@ -22,9 +22,9 @@ The field above the list adds a task to the day on screen. Type a title
and press Enter. That is the fast path, meant for capturing work while
you are on the phone.
-**New task** opens the full form, where a task also gets a priority and
-an optional contact. Search for the person by name and pick them, and the
-task is linked to them from both sides.
+**New task** opens the full form, where a task also gets a due date, a
+priority and an optional contact. Search for the person by name and pick
+them, and the task is linked to them from both sides.
## Completing
@@ -42,15 +42,19 @@ something the app does behind your back.
## Rescheduling
-**Push to tomorrow** moves a task to the day after today, whatever it was
-due before. A task that has been overdue for a week lands on tomorrow,
-not on a date that is still in the past. This is the no-answer pattern:
-call, no reply, push, and it comes back tomorrow.
+**Postpone** moves an open task one day later. A task due today, or overdue
+for a week, lands on tomorrow, never on a date that is still in the past. A
+task already due on a later day moves to the day after it. This is the
+no-answer pattern: call, no reply, postpone, and it comes back tomorrow.
For any other date, open the task and edit its due date directly.
## Tasks on a contact
A contact's page lists the open tasks linked to that person, across
-everyone on the team, and has its own field for adding one. It answers
+everyone on the team, and has its own form for adding one. It answers
the question you have while looking at somebody: what do we owe them.
+
+Type a title, pick the day it is due, and press **Add task**. The date
+starts on today and goes back to today after each task, so a follow-up for
+Friday takes one extra click, not a trip to the task's own page.
diff --git a/frontend/package.json b/frontend/package.json
index a9abfbb..7b86c4f 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -22,7 +22,7 @@
"@alphone/plugin-fields": "workspace:*",
"@alphone/plugin-importer": "workspace:*",
"@alphone/plugin-whatsapp": "workspace:*",
- "@gopherium/godmin": "0.9.0",
+ "@gopherium/godmin": "0.10.0",
"@gopherium/gottext": "0.5.0",
"@gopherium/react-auth": "0.9.0",
"@tanstack/react-query": "^5.102.8",
diff --git a/frontend/src/contacts/ContactScreen.tsx b/frontend/src/contacts/ContactScreen.tsx
index 2b6058b..eab50e0 100644
--- a/frontend/src/contacts/ContactScreen.tsx
+++ b/frontend/src/contacts/ContactScreen.tsx
@@ -50,7 +50,7 @@ export interface ContactDetail {
}
/**
- * Renders one contact's detail: rename form, identities, and creation date.
+ * Renders one contact's detail: its name, identities and tasks beside its creation date and panels.
* @returns The contact screen.
*/
export function ContactScreen({ contactId }: { contactId: string }) {
@@ -72,7 +72,17 @@ export function ContactScreen({ contactId }: { contactId: string }) {
return {__('The contact could not be loaded.', 'alphone')}
}
return (
-
+
+
+ {sprintf(__('Created %(date)s', 'alphone'), { date: formatCreated(new Date(contact.createdAt)) })}
+
+
+ >
+ }
+ >
}>
{__('Identities', 'alphone')}
@@ -80,10 +90,6 @@ export function ContactScreen({ contactId }: { contactId: string }) {
-
-
- {sprintf(__('Created %(date)s', 'alphone'), { date: formatCreated(new Date(contact.createdAt)) })}
-
)
}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 69e12e6..6d724fb 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -369,6 +369,10 @@
gap: var(--wpds-dimension-gap-sm);
}
+.alphone-tasks__add--contact {
+ flex-wrap: wrap;
+}
+
.alphone-tasks__list {
list-style: none;
margin: 0;
@@ -455,6 +459,18 @@
gap: var(--wpds-dimension-gap-md);
}
+.alphone-tasks__contact-block .alphone-tasks__row {
+ flex-wrap: wrap;
+}
+
+.alphone-tasks__contact-block .alphone-tasks__title {
+ flex: 1 1 50%;
+}
+
+.alphone-tasks__contact-block .alphone-tasks__push {
+ margin-left: auto;
+}
+
/* Small viewport overrides live together at the end of the sheet, so they
always win over the rules above them without needing extra specificity. */
@media (max-width: 639px) {
diff --git a/frontend/src/languages/es-ES.json b/frontend/src/languages/es-ES.json
index 37faba7..a20ab51 100644
--- a/frontend/src/languages/es-ES.json
+++ b/frontend/src/languages/es-ES.json
@@ -1 +1 @@
-{"":{"lang":"es","plural-forms":""},"30 days":["30 días"],"90 days":["90 días"],"A contact needs a name.":["Un contacto necesita un nombre."],"A lifetime is zero days or more.":["Una duración es de cero días o más."],"A lifetime runs to %(maxDays)d days at most.":["Una duración llega como mucho a %(maxDays)d días."],"A name runs to %(max)d characters at most.":["Un nombre tiene como mucho %(max)d caracteres."],"A password runs to %(max)d characters at most.":["Una contraseña tiene como mucho %(max)d caracteres."],"A password runs to %(min)d characters at least.":["Una contraseña tiene al menos %(min)d caracteres."],"A task needs a title.":["Una tarea necesita un título."],"A token needs a name.":["Un token necesita un nombre."],"A year":["Un año"],"API tokens":["Tokens de API"],"Actions":["Acciones"],"Activation link":["Enlace de activación"],"Add a task":["Añadir una tarea"],"Add a task above, or open a contact and create one from their page.":["Añade una tarea arriba, o abre un contacto y crea una desde su página."],"Add a task for this contact":["Añadir una tarea para este contacto"],"Add identity":["Añadir identidad"],"Add one with New token.":["Añade uno con Token nuevo."],"Add one with New user.":["Añade uno con Usuario nuevo."],"Add task":["Añadir tarea"],"AlphOne cannot write to that channel.":["AlphOne no puede escribir en ese canal."],"AlphOne does not speak that language yet.":["AlphOne todavía no habla ese idioma."],"Already on contact %(name)s.":["Ya está en el contacto %(name)s."],"An account needs a name.":["Una cuenta necesita un nombre."],"An event needs the source it came from.":["Un evento necesita la fuente de la que viene."],"Another account already holds that address.":["Ya hay otra cuenta que tiene esa dirección."],"Another contact already holds that address.":["Ya hay otro contacto que tiene esa dirección."],"Areas":["Áreas"],"Ask for between %(min)d and %(max)d at a time.":["Pide entre %(min)d y %(max)d a la vez."],"Ask for tasks by one filter at a time.":["Pide las tareas con un solo filtro a la vez."],"Channel":["Canal"],"Choose at least one area the token reaches.":["Elige al menos un área a la que llegue el token."],"Choose at least one event to send.":["Elige al menos un evento que enviar."],"Choose the channel this address belongs to.":["Elige el canal al que pertenece esta dirección."],"Complete":["Completar"],"Contact":["Contacto"],"Contact tasks":["Tareas del contacto"],"Contacts":["Contactos"],"Contacts could not be loaded.":["No se han podido cargar los contactos."],"Copied.":["Copiado."],"Copy it by hand.":["Cópialo a mano."],"Copy secret":["Copiar el secreto"],"Copy the secret now, it is never shown again.":["Copia el secreto ahora, no se vuelve a mostrar nunca."],"Create contact":["Crear contacto"],"Create task":["Crear tarea"],"Create token":["Crear token"],"Created":["Creado"],"Created %(date)s":["Creado el %(date)s"],"Disable":["Desactivar"],"Disable %(name)s":["Desactivar a %(name)s"],"Done":["Hecho"],"Done (%(count)s)":["Hechas (%(count)s)"],"Done tasks":["Tareas hechas"],"Due %(date)s":["Vence el %(date)s"],"Due date":["Fecha de vencimiento"],"Email":["Correo electrónico"],"Enable":["Activar"],"Enable %(name)s":["Activar a %(name)s"],"Expires":["Caduca"],"Identities":["Identidades"],"Invitation sent.":["Invitación enviada."],"Label":["Etiqueta"],"Language":["Idioma"],"Last used":["Último uso"],"Link a contact":["Vincular un contacto"],"Load more":["Cargar más"],"Load more done":["Cargar más hechas"],"Load more overdue":["Cargar más vencidas"],"Load more tasks":["Cargar más tareas"],"Loading contacts…":["Cargando los contactos…"],"Loading contact…":["Cargando el contacto…"],"Loading tasks…":["Cargando las tareas…"],"Loading task…":["Cargando la tarea…"],"Loading tokens…":["Cargando los tokens…"],"Loading users…":["Cargando los usuarios…"],"Loading…":["Cargando…"],"Name":["Nombre"],"Navigation":["Navegación"],"Never expires":["No caduca nunca"],"Never used":["Sin usar"],"New API token":["Token de API nuevo"],"New contact":["Contacto nuevo"],"New task":["Tarea nueva"],"New task for this contact":["Tarea nueva para este contacto"],"New token":["Token nuevo"],"New user":["Usuario nuevo"],"Next day":["Día siguiente"],"No API tokens yet.":["Todavía no hay tokens de API."],"No contacts found.":["No se ha encontrado ningún contacto."],"No identities yet.":["Todavía no hay identidades."],"No mail server is configured. Deliver the activation link by hand.":["No hay servidor de correo configurado. Entrega el enlace de activación a mano."],"No open tasks.":["No hay tareas abiertas."],"No role":["Sin rol"],"No users yet.":["Todavía no hay usuarios."],"Nothing due today.":["No hay nada que venza hoy."],"Open tasks":["Tareas abiertas"],"Overdue":["Vencidas"],"Overdue tasks":["Tareas vencidas"],"Overdue tasks could not be loaded.":["No se han podido cargar las tareas vencidas."],"Previous day":["Día anterior"],"Priority":["Prioridad"],"Push to tomorrow":["Pasar a mañana"],"Read %(area)s":["Leer %(area)s"],"Reading the languages":["Leyendo los idiomas"],"Remove":["Quitar"],"Remove %(identifier)s":["Quitar %(identifier)s"],"Remove %(name)s":["Quitar a %(name)s"],"Reopen":["Reabrir"],"Resend invitation":["Reenviar invitación"],"Resend invitation to %(name)s":["Reenviar invitación a %(name)s"],"Revoke":["Revocar"],"Revoke %(name)s":["Revocar %(name)s"],"Revoke failed.":["No se ha podido revocar."],"Role":["Rol"],"Role of %(name)s":["Rol de %(name)s"],"Save":["Guardar"],"Scopes":["Ámbitos"],"Search contacts":["Buscar contactos"],"Send invitation":["Enviar invitación"],"Sign in to go on.":["Inicia sesión para continuar."],"Somebody must be able to manage users.":["Alguien tiene que poder gestionar los usuarios."],"Something went wrong.":["Algo ha ido mal."],"Something went wrong. Try again.":["Algo ha ido mal. Inténtalo de nuevo."],"Status":["Estado"],"Task":["Tarea"],"Tasks":["Tareas"],"Tasks could not be loaded.":["No se han podido cargar las tareas."],"That account no longer exists.":["Esa cuenta ya no existe."],"That address is no longer on this contact.":["Esa dirección ya no está en este contacto."],"That area is not one AlphOne offers.":["Esa área no es una de las que AlphOne ofrece."],"That contact no longer exists.":["Ese contacto ya no existe."],"That email and password do not match an account.":["Ese correo y esa contraseña no coinciden con ninguna cuenta."],"That event name is not one AlphOne knows.":["Ese nombre de evento no es uno de los que AlphOne conoce."],"That page marker is not one AlphOne issued.":["Ese marcador de página no es uno de los que AlphOne ha emitido."],"That priority is not one AlphOne offers.":["Esa prioridad no es una de las que AlphOne ofrece."],"That role holds more than your own.":["Ese rol abarca más que el tuyo."],"That role is not one this deployment names.":["Ese rol no es uno de los que este despliegue nombra."],"That status is not one AlphOne offers.":["Ese estado no es uno de los que AlphOne ofrece."],"That task no longer exists.":["Esa tarea ya no existe."],"That token no longer exists.":["Ese token ya no existe."],"That value is not in the form AlphOne expects.":["Ese valor no tiene la forma que AlphOne espera."],"That webhook no longer exists.":["Ese webhook ya no existe."],"The choice could not be saved.":["No se ha podido guardar la elección."],"The contact could not be created.":["No se ha podido crear el contacto."],"The contact could not be loaded.":["No se ha podido cargar el contacto."],"The contact could not be renamed.":["No se ha podido renombrar el contacto."],"The identity could not be added.":["No se ha podido añadir la identidad."],"The identity could not be removed.":["No se ha podido quitar la identidad."],"The invitation could not be sent.":["No se pudo enviar la invitación."],"The language changes when the page next loads.":["El idioma cambia la próxima vez que se cargue la página."],"The languages could not be read.":["No se han podido leer los idiomas."],"The task could not be added.":["No se ha podido añadir la tarea."],"The task could not be created.":["No se ha podido crear la tarea."],"The task could not be loaded.":["No se ha podido cargar la tarea."],"The task could not be saved.":["No se ha podido guardar la tarea."],"The task could not be updated.":["No se ha podido actualizar la tarea."],"The token could not be created.":["No se ha podido crear el token."],"This link is no longer valid. Ask for a new one.":["Este enlace ya no es válido. Pide uno nuevo."],"This token does not reach %(scope)s.":["Este token no alcanza %(scope)s."],"This workspace has been deactivated. Contact your operator.":["Este espacio de trabajo ha sido desactivado. Contacta con tu operador."],"Title":["Título"],"Today":["Hoy"],"Tokens could not be loaded.":["No se han podido cargar los tokens."],"Too many attempts. Wait %(retryAfter)d seconds and try again.":["Demasiados intentos. Espera %(retryAfter)d segundos e inténtalo de nuevo."],"Try a different search, or add one with New contact.":["Prueba otra búsqueda, o añade uno con Contacto nuevo."],"Users":["Usuarios"],"Users could not be loaded.":["No se han podido cargar los usuarios."],"Value":["Valor"],"Write %(area)s":["Escribir %(area)s"],"Write a scope as an area, a colon, then read or write.":["Escribe un ámbito como un área, dos puntos y luego read o write."],"Write the address as a full URL.":["Escribe la dirección como una URL completa."],"Write the address as a full email address.":["Escribe la dirección como una dirección de correo electrónico completa."],"Write the address itself.":["Escribe la dirección."],"You cannot change your own role.":["No puedes cambiar tu propio rol."],"You cannot disable your own account.":["No puedes desactivar tu propia cuenta."],"Your role does not allow %(capability)s.":["Tu rol no permite %(capability)s."],"account status\u0004Active":["Activa"],"account status\u0004Disabled":["Desactivada"],"account status\u0004Invited":["Invitada"],"admin section\u0004Contacts":["Contactos"],"admin section\u0004Language":["Idioma"],"admin section\u0004Tasks":["Tareas"],"contact channel\u0004Email":["Correo electrónico"],"contact channel\u0004Phone":["Teléfono"],"task priority\u0004High":["Alta"],"task priority\u0004Normal":["Normal"],"token expiry\u0004Expired":["Caducado"],"token lifetime\u0004Never":["Nunca"],"v%(version)s":["v%(version)s"]}
+{"":{"lang":"es","plural-forms":""},"30 days":["30 días"],"90 days":["90 días"],"A contact needs a name.":["Un contacto necesita un nombre."],"A lifetime is zero days or more.":["Una duración es de cero días o más."],"A lifetime runs to %(maxDays)d days at most.":["Una duración llega como mucho a %(maxDays)d días."],"A name runs to %(max)d characters at most.":["Un nombre tiene como mucho %(max)d caracteres."],"A password runs to %(max)d characters at most.":["Una contraseña tiene como mucho %(max)d caracteres."],"A password runs to %(min)d characters at least.":["Una contraseña tiene al menos %(min)d caracteres."],"A task needs a title.":["Una tarea necesita un título."],"A token needs a name.":["Un token necesita un nombre."],"A year":["Un año"],"API tokens":["Tokens de API"],"Actions":["Acciones"],"Activation link":["Enlace de activación"],"Add a task":["Añadir una tarea"],"Add a task above, or open a contact and create one from their page.":["Añade una tarea arriba, o abre un contacto y crea una desde su página."],"Add a task for this contact":["Añadir una tarea para este contacto"],"Add identity":["Añadir identidad"],"Add one with New token.":["Añade uno con Token nuevo."],"Add one with New user.":["Añade uno con Usuario nuevo."],"Add task":["Añadir tarea"],"AlphOne cannot write to that channel.":["AlphOne no puede escribir en ese canal."],"AlphOne does not speak that language yet.":["AlphOne todavía no habla ese idioma."],"Already on contact %(name)s.":["Ya está en el contacto %(name)s."],"An account needs a name.":["Una cuenta necesita un nombre."],"An event needs the source it came from.":["Un evento necesita la fuente de la que viene."],"Another account already holds that address.":["Ya hay otra cuenta que tiene esa dirección."],"Another contact already holds that address.":["Ya hay otro contacto que tiene esa dirección."],"Areas":["Áreas"],"Ask for between %(min)d and %(max)d at a time.":["Pide entre %(min)d y %(max)d a la vez."],"Ask for tasks by one filter at a time.":["Pide las tareas con un solo filtro a la vez."],"Channel":["Canal"],"Choose at least one area the token reaches.":["Elige al menos un área a la que llegue el token."],"Choose at least one event to send.":["Elige al menos un evento que enviar."],"Choose the channel this address belongs to.":["Elige el canal al que pertenece esta dirección."],"Complete":["Completar"],"Contact":["Contacto"],"Contact tasks":["Tareas del contacto"],"Contacts":["Contactos"],"Contacts could not be loaded.":["No se han podido cargar los contactos."],"Copied.":["Copiado."],"Copy it by hand.":["Cópialo a mano."],"Copy secret":["Copiar el secreto"],"Copy the secret now, it is never shown again.":["Copia el secreto ahora, no se vuelve a mostrar nunca."],"Create contact":["Crear contacto"],"Create task":["Crear tarea"],"Create token":["Crear token"],"Created":["Creado"],"Created %(date)s":["Creado el %(date)s"],"Disable":["Desactivar"],"Disable %(name)s":["Desactivar a %(name)s"],"Done":["Hecho"],"Done (%(count)s)":["Hechas (%(count)s)"],"Done tasks":["Tareas hechas"],"Due %(date)s":["Vence el %(date)s"],"Due date":["Fecha de vencimiento"],"Email":["Correo electrónico"],"Enable":["Activar"],"Enable %(name)s":["Activar a %(name)s"],"Expires":["Caduca"],"Identities":["Identidades"],"Invitation sent.":["Invitación enviada."],"Label":["Etiqueta"],"Language":["Idioma"],"Last used":["Último uso"],"Link a contact":["Vincular un contacto"],"Load more":["Cargar más"],"Load more done":["Cargar más hechas"],"Load more overdue":["Cargar más vencidas"],"Load more tasks":["Cargar más tareas"],"Loading contacts…":["Cargando los contactos…"],"Loading contact…":["Cargando el contacto…"],"Loading tasks…":["Cargando las tareas…"],"Loading task…":["Cargando la tarea…"],"Loading tokens…":["Cargando los tokens…"],"Loading users…":["Cargando los usuarios…"],"Loading…":["Cargando…"],"Name":["Nombre"],"Navigation":["Navegación"],"Never expires":["No caduca nunca"],"Never used":["Sin usar"],"New API token":["Token de API nuevo"],"New contact":["Contacto nuevo"],"New task":["Tarea nueva"],"New task for this contact":["Tarea nueva para este contacto"],"New token":["Token nuevo"],"New user":["Usuario nuevo"],"Next day":["Día siguiente"],"No API tokens yet.":["Todavía no hay tokens de API."],"No contacts found.":["No se ha encontrado ningún contacto."],"No identities yet.":["Todavía no hay identidades."],"No mail server is configured. Deliver the activation link by hand.":["No hay servidor de correo configurado. Entrega el enlace de activación a mano."],"No open tasks.":["No hay tareas abiertas."],"No role":["Sin rol"],"No users yet.":["Todavía no hay usuarios."],"Nothing due today.":["No hay nada que venza hoy."],"Open tasks":["Tareas abiertas"],"Overdue":["Vencidas"],"Overdue tasks":["Tareas vencidas"],"Overdue tasks could not be loaded.":["No se han podido cargar las tareas vencidas."],"Postpone":["Posponer"],"Previous day":["Día anterior"],"Priority":["Prioridad"],"Read %(area)s":["Leer %(area)s"],"Reading the languages":["Leyendo los idiomas"],"Remove":["Quitar"],"Remove %(identifier)s":["Quitar %(identifier)s"],"Remove %(name)s":["Quitar a %(name)s"],"Reopen":["Reabrir"],"Resend invitation":["Reenviar invitación"],"Resend invitation to %(name)s":["Reenviar invitación a %(name)s"],"Revoke":["Revocar"],"Revoke %(name)s":["Revocar %(name)s"],"Revoke failed.":["No se ha podido revocar."],"Role":["Rol"],"Role of %(name)s":["Rol de %(name)s"],"Save":["Guardar"],"Scopes":["Ámbitos"],"Search contacts":["Buscar contactos"],"Send invitation":["Enviar invitación"],"Sign in to go on.":["Inicia sesión para continuar."],"Somebody must be able to manage users.":["Alguien tiene que poder gestionar los usuarios."],"Something went wrong.":["Algo ha ido mal."],"Something went wrong. Try again.":["Algo ha ido mal. Inténtalo de nuevo."],"Status":["Estado"],"Task":["Tarea"],"Tasks":["Tareas"],"Tasks could not be loaded.":["No se han podido cargar las tareas."],"That account no longer exists.":["Esa cuenta ya no existe."],"That address is no longer on this contact.":["Esa dirección ya no está en este contacto."],"That area is not one AlphOne offers.":["Esa área no es una de las que AlphOne ofrece."],"That contact no longer exists.":["Ese contacto ya no existe."],"That email and password do not match an account.":["Ese correo y esa contraseña no coinciden con ninguna cuenta."],"That event name is not one AlphOne knows.":["Ese nombre de evento no es uno de los que AlphOne conoce."],"That page marker is not one AlphOne issued.":["Ese marcador de página no es uno de los que AlphOne ha emitido."],"That priority is not one AlphOne offers.":["Esa prioridad no es una de las que AlphOne ofrece."],"That role holds more than your own.":["Ese rol abarca más que el tuyo."],"That role is not one this deployment names.":["Ese rol no es uno de los que este despliegue nombra."],"That status is not one AlphOne offers.":["Ese estado no es uno de los que AlphOne ofrece."],"That task no longer exists.":["Esa tarea ya no existe."],"That token no longer exists.":["Ese token ya no existe."],"That value is not in the form AlphOne expects.":["Ese valor no tiene la forma que AlphOne espera."],"That webhook no longer exists.":["Ese webhook ya no existe."],"The choice could not be saved.":["No se ha podido guardar la elección."],"The contact could not be created.":["No se ha podido crear el contacto."],"The contact could not be loaded.":["No se ha podido cargar el contacto."],"The contact could not be renamed.":["No se ha podido renombrar el contacto."],"The identity could not be added.":["No se ha podido añadir la identidad."],"The identity could not be removed.":["No se ha podido quitar la identidad."],"The invitation could not be sent.":["No se pudo enviar la invitación."],"The language changes when the page next loads.":["El idioma cambia la próxima vez que se cargue la página."],"The languages could not be read.":["No se han podido leer los idiomas."],"The task could not be added.":["No se ha podido añadir la tarea."],"The task could not be created.":["No se ha podido crear la tarea."],"The task could not be loaded.":["No se ha podido cargar la tarea."],"The task could not be saved.":["No se ha podido guardar la tarea."],"The task could not be updated.":["No se ha podido actualizar la tarea."],"The token could not be created.":["No se ha podido crear el token."],"This link is no longer valid. Ask for a new one.":["Este enlace ya no es válido. Pide uno nuevo."],"This token does not reach %(scope)s.":["Este token no alcanza %(scope)s."],"This workspace has been deactivated. Contact your operator.":["Este espacio de trabajo ha sido desactivado. Contacta con tu operador."],"Title":["Título"],"Today":["Hoy"],"Tokens could not be loaded.":["No se han podido cargar los tokens."],"Too many attempts. Wait %(retryAfter)d seconds and try again.":["Demasiados intentos. Espera %(retryAfter)d segundos e inténtalo de nuevo."],"Try a different search, or add one with New contact.":["Prueba otra búsqueda, o añade uno con Contacto nuevo."],"Users":["Usuarios"],"Users could not be loaded.":["No se han podido cargar los usuarios."],"Value":["Valor"],"Write %(area)s":["Escribir %(area)s"],"Write a scope as an area, a colon, then read or write.":["Escribe un ámbito como un área, dos puntos y luego read o write."],"Write the address as a full URL.":["Escribe la dirección como una URL completa."],"Write the address as a full email address.":["Escribe la dirección como una dirección de correo electrónico completa."],"Write the address itself.":["Escribe la dirección."],"You cannot change your own role.":["No puedes cambiar tu propio rol."],"You cannot disable your own account.":["No puedes desactivar tu propia cuenta."],"Your role does not allow %(capability)s.":["Tu rol no permite %(capability)s."],"account status\u0004Active":["Activa"],"account status\u0004Disabled":["Desactivada"],"account status\u0004Invited":["Invitada"],"admin section\u0004Contacts":["Contactos"],"admin section\u0004Language":["Idioma"],"admin section\u0004Tasks":["Tareas"],"contact channel\u0004Email":["Correo electrónico"],"contact channel\u0004Phone":["Teléfono"],"task priority\u0004High":["Alta"],"task priority\u0004Normal":["Normal"],"token expiry\u0004Expired":["Caducado"],"token lifetime\u0004Never":["Nunca"],"v%(version)s":["v%(version)s"]}
diff --git a/frontend/src/tasks/ContactTasks.tsx b/frontend/src/tasks/ContactTasks.tsx
index 30362f2..418abbc 100644
--- a/frontend/src/tasks/ContactTasks.tsx
+++ b/frontend/src/tasks/ContactTasks.tsx
@@ -12,7 +12,7 @@ import { graphError, useGraph, useGraphMutation } from '@alphone/frontend-sdk'
import type { ConnectionResult } from '@alphone/frontend-sdk'
import { useState } from 'react'
-import { isoDate, laterDate, shiftDate } from './format'
+import { isValidDate, isoDate, laterDate, shiftDate } from './format'
import { createTaskMutation, updateTaskMutation } from './operations'
import { TaskList } from './TaskList'
import type { RowControls, ListedTask } from './TaskList'
@@ -21,7 +21,7 @@ import type { RowControls, ListedTask } from './TaskList'
const contactDetailOperation = 'ContactDetail'
/**
- * Renders a contact's open tasks and the field that adds one.
+ * Renders a contact's open tasks and the form that adds one.
* @returns The contact tasks section.
*/
export function ContactTasks({
@@ -33,19 +33,10 @@ export function ContactTasks({
}) {
const today = isoDate(new Date())
const graph = useGraph()
- const [title, setTitle] = useState('')
const [pendingID, setPendingID] = useState('')
- const [add, runAdd] = useGraphMutation(createTaskMutation)
const [change, runChange] = useGraphMutation(updateTaskMutation)
const [push, runPush] = useGraphMutation(updateTaskMutation)
const settled = () => graph.refetch([contactDetailOperation])
- const submitAdd = async () => {
- const result = await runAdd({ input: { title, dueOn: today, contactId } })
- if (result.data) {
- setTitle('')
- settled()
- }
- }
const completeTask = async (task: ListedTask) => {
setPendingID(task.id)
const result = await runChange({ id: task.id, input: { status: 'done' } })
@@ -69,8 +60,45 @@ export function ContactTasks({
}>
{__('Tasks', 'alphone')}
+
+ {change.error || push.error ? (
+ {__('The task could not be updated.', 'alphone')}
+ ) : null}
+ void completeTask(task),
+ onPush: (task) => void pushTask(task),
+ pendingID,
+ }}
+ />
+
+ )
+}
+
+/**
+ * Renders the form that adds a task to a contact on a chosen day.
+ * @param props - The contact the task belongs to and what to call once it is added.
+ * @returns The add task form and its error.
+ */
+function AddContactTaskForm({ contactId, onAdded }: { contactId: string; onAdded: () => void }) {
+ const [title, setTitle] = useState('')
+ const [picked, setPicked] = useState(null)
+ const [add, runAdd] = useGraphMutation(createTaskMutation)
+ const dueOn = picked ?? isoDate(new Date())
+ const submitAdd = async () => {
+ const result = await runAdd({ input: { title, dueOn, contactId } })
+ if (result.data) {
+ setTitle('')
+ setPicked(null)
+ onAdded()
+ }
+ }
+
+ return (
+ <>