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 ( + <>
{ event.preventDefault() void submitAdd() @@ -83,9 +111,15 @@ export function ContactTasks({ value={title} onChange={(event) => setTitle(event.target.value)} /> + setPicked(event.target.value)} + /> )} diff --git a/frontend/src/tasks/TaskScreen.tsx b/frontend/src/tasks/TaskScreen.tsx index 98a4f48..42ff32a 100644 --- a/frontend/src/tasks/TaskScreen.tsx +++ b/frontend/src/tasks/TaskScreen.tsx @@ -17,6 +17,7 @@ import { import { Link } from '@tanstack/react-router' import { useState } from 'react' +import { isValidDate } from './format' import { taskDetailQuery, updateTaskMutation } from './operations' import { PrioritySelect } from './PrioritySelect' @@ -117,7 +118,7 @@ function TaskForm({ task }: { task: DetailedTask }) {