Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
a0d6c7e
test(e2e): press Postpone to move a task a day later
SirLouen Sep 26, 2026
0448d4a
feat(frontend): call the reschedule button Postpone
SirLouen Sep 26, 2026
98293f1
docs(tasks): describe Postpone for every due date
SirLouen Sep 26, 2026
cbf941e
test(e2e): add a contact task on a chosen day
SirLouen Sep 26, 2026
92129f6
refactor(frontend): give the contact task form its own component
SirLouen Sep 26, 2026
6d687d5
feat(frontend): pick the due date of a task added on a contact
SirLouen Sep 26, 2026
b9148d6
fix(frontend): keep the task forms from saving an empty due date
SirLouen Sep 26, 2026
bb6d33d
docs(tasks): say a task added on a contact takes a due date
SirLouen Sep 26, 2026
d2b94ee
feat(seed): give the history contact a task due in three days
SirLouen Sep 26, 2026
e2888fd
test(e2e): find the contact page inputs by their exact names
SirLouen Sep 26, 2026
d5cc92b
test(e2e): pin the fields beside the tasks on a contact
SirLouen Sep 26, 2026
bcf1ed7
chore(deps): move to godmin 0.10.0
SirLouen Sep 26, 2026
b876fdd
feat(frontend): set a contact's details beside its work
SirLouen Sep 26, 2026
c38d5c2
fix(frontend): keep contact task titles readable in a narrow column
SirLouen Sep 26, 2026
115bbb5
docs(contacts): describe the two columns of a contact page
SirLouen Sep 26, 2026
d6b5136
docs(extending): describe the page aside and the panel width
SirLouen Sep 26, 2026
f5d9c86
fix(fields): seed the history onto the contact holding the demo email
SirLouen Sep 26, 2026
d758b93
fix(fields): seed the history onto the contact holding the demo email
SirLouen Sep 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 41 additions & 11 deletions cmd/alphone/seed.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -115,14 +112,41 @@ 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"},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

// 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
title string
offset int
priority int
done bool
linked bool
contact string
origin string
member bool
}
Expand All @@ -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},
{
Expand All @@ -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",
},
}
}

Expand All @@ -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 {
Expand All @@ -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
}
Expand All @@ -203,7 +233,7 @@ func buildDemoTask(
Priority: scripted.priority,
AssigneeID: assigneeID,
}
if scripted.linked {
if scripted.contact != "" {
in.ContactID = contactID
}
if scripted.origin != "" {
Expand Down
35 changes: 29 additions & 6 deletions cmd/alphone/seed_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -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)
Expand All @@ -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") {
Expand Down
8 changes: 8 additions & 0 deletions docs/src/content/docs/extending/screens.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
7 changes: 7 additions & 0 deletions docs/src/content/docs/guides/contacts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions docs/src/content/docs/guides/fields.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 13 additions & 9 deletions docs/src/content/docs/guides/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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.
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 12 additions & 6 deletions frontend/src/contacts/ContactScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand All @@ -72,18 +72,24 @@ export function ContactScreen({ contactId }: { contactId: string }) {
return <ErrorNotice>{__('The contact could not be loaded.', 'alphone')}</ErrorNotice>
}
return (
<PageScreen title={contact.name}>
<PageScreen
title={contact.name}
aside={
<>
<Text className="alphone-contacts__created">
{sprintf(__('Created %(date)s', 'alphone'), { date: formatCreated(new Date(contact.createdAt)) })}
</Text>
<ContactPanels contactId={contact.id} panels={contactPanels} />
</>
}
>
<RenameForm key={contact.name} contact={contact} />
<Text variant="heading-sm" render={<h2 />}>
{__('Identities', 'alphone')}
</Text>
<IdentityList contact={contact} />
<AddIdentityForm contact={contact} />
<ContactTasks contactId={contact.id} tasks={detail} />
<ContactPanels contactId={contact.id} panels={contactPanels} />
<Text className="alphone-contacts__created">
{sprintf(__('Created %(date)s', 'alphone'), { date: formatCreated(new Date(contact.createdAt)) })}
</Text>
</PageScreen>
)
}
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading