diff --git a/lib/kith/contacts/activity_stream.ex b/lib/kith/contacts/activity_stream.ex
new file mode 100644
index 0000000..e3f987b
--- /dev/null
+++ b/lib/kith/contacts/activity_stream.ex
@@ -0,0 +1,239 @@
+defmodule Kith.Contacts.ActivityStream do
+ @moduledoc """
+ Unified activity stream for a contact, merging all entry types
+ (notes, calls, life events, activities, tasks, gifts, conversations, photos)
+ into a single chronological timeline.
+ """
+
+ alias Kith.Activities
+ alias Kith.Contacts
+ alias Kith.Conversations
+ alias Kith.Gifts
+ alias Kith.Tasks
+
+ @type entry_type ::
+ :note | :call | :life_event | :activity | :task | :gift | :conversation | :photo
+
+ @all_types ~w(note call life_event activity task gift conversation photo)a
+
+ @doc """
+ Lists activity entries for a contact, merged into a single chronological stream.
+
+ ## Options
+
+ * `:types` - list of entry types to include (default: all types)
+ * `:limit` - max entries to return (default: 20)
+ * `:current_user_id` - required, used for privacy filtering on notes
+
+ Returns a list of maps with normalized shape:
+
+ %{
+ id: integer,
+ type: atom,
+ title: string,
+ body: string | nil,
+ occurred_at: DateTime.t(),
+ record: struct # the original schema struct
+ }
+ """
+ @spec list_activity(integer(), integer(), keyword()) :: [map()]
+ def list_activity(account_id, contact_id, opts \\ []) do
+ types = Keyword.get(opts, :types, @all_types)
+ limit = Keyword.get(opts, :limit, 20)
+ current_user_id = Keyword.fetch!(opts, :current_user_id)
+
+ types
+ |> Enum.flat_map(&fetch_entries(&1, account_id, contact_id, current_user_id))
+ |> Enum.sort_by(& &1.occurred_at, {:desc, DateTime})
+ |> Enum.take(limit)
+ end
+
+ @doc """
+ Returns all supported entry types.
+ """
+ def all_types, do: @all_types
+
+ # --- Private fetchers per type ---
+
+ defp fetch_entries(:note, _account_id, contact_id, current_user_id) do
+ contact_id
+ |> Contacts.list_notes(current_user_id)
+ |> Enum.map(&normalize_note/1)
+ end
+
+ defp fetch_entries(:call, _account_id, contact_id, _current_user_id) do
+ contact_id
+ |> Activities.list_calls()
+ |> Enum.map(&normalize_call/1)
+ end
+
+ defp fetch_entries(:life_event, _account_id, contact_id, _current_user_id) do
+ contact_id
+ |> Activities.list_life_events()
+ |> Enum.map(&normalize_life_event/1)
+ end
+
+ defp fetch_entries(:activity, _account_id, contact_id, _current_user_id) do
+ contact_id
+ |> Activities.list_activities_for_contact()
+ |> Enum.map(&normalize_activity/1)
+ end
+
+ defp fetch_entries(:task, account_id, contact_id, _current_user_id) do
+ account_id
+ |> Tasks.list_tasks(contact_id: contact_id)
+ |> Enum.map(&normalize_task/1)
+ end
+
+ defp fetch_entries(:gift, account_id, contact_id, _current_user_id) do
+ account_id
+ |> Gifts.list_gifts(contact_id)
+ |> Enum.map(&normalize_gift/1)
+ end
+
+ defp fetch_entries(:conversation, account_id, contact_id, _current_user_id) do
+ account_id
+ |> Conversations.list_conversations(contact_id)
+ |> Enum.map(&normalize_conversation/1)
+ end
+
+ defp fetch_entries(:photo, _account_id, contact_id, _current_user_id) do
+ contact_id
+ |> Contacts.list_photos()
+ |> Enum.map(&normalize_photo/1)
+ end
+
+ # --- Normalizers ---
+
+ defp normalize_note(note) do
+ %{
+ id: note.id,
+ type: :note,
+ title: truncate(strip_html(note.body), 80),
+ body: note.body,
+ occurred_at: note.inserted_at,
+ record: note
+ }
+ end
+
+ defp normalize_call(call) do
+ direction = if call.call_direction, do: call.call_direction.name, else: nil
+
+ title =
+ [direction, duration_text(call.duration_mins)]
+ |> Enum.reject(&is_nil/1)
+ |> Enum.join(" \u00b7 ")
+ |> then(fn
+ "" -> "Call"
+ text -> text
+ end)
+
+ %{
+ id: call.id,
+ type: :call,
+ title: title,
+ body: call.notes,
+ occurred_at: call.occurred_at,
+ record: call
+ }
+ end
+
+ defp normalize_life_event(event) do
+ type_name = if event.life_event_type, do: event.life_event_type.name, else: nil
+
+ %{
+ id: event.id,
+ type: :life_event,
+ title: type_name || "Life event",
+ body: event.note,
+ occurred_at: date_to_datetime(event.occurred_on),
+ record: event
+ }
+ end
+
+ defp normalize_activity(activity) do
+ %{
+ id: activity.id,
+ type: :activity,
+ title: activity.title || "Activity",
+ body: activity.description,
+ occurred_at: activity.occurred_at,
+ record: activity
+ }
+ end
+
+ defp normalize_task(task) do
+ %{
+ id: task.id,
+ type: :task,
+ title: task.title,
+ body: task.description,
+ occurred_at: task.inserted_at,
+ record: task
+ }
+ end
+
+ defp normalize_gift(gift) do
+ %{
+ id: gift.id,
+ type: :gift,
+ title: gift.name || "Gift",
+ body: gift.description,
+ occurred_at: gift.inserted_at,
+ record: gift
+ }
+ end
+
+ defp normalize_conversation(conversation) do
+ message_count = length(conversation.messages)
+ last_message = List.first(conversation.messages)
+ body_preview = if last_message, do: "#{message_count} messages", else: nil
+
+ %{
+ id: conversation.id,
+ type: :conversation,
+ title: conversation.subject || conversation.platform || "Conversation",
+ body: body_preview,
+ occurred_at: conversation.updated_at,
+ record: conversation
+ }
+ end
+
+ defp normalize_photo(photo) do
+ %{
+ id: photo.id,
+ type: :photo,
+ title: photo.file_name || "Photo",
+ body: nil,
+ occurred_at: photo.inserted_at,
+ record: photo
+ }
+ end
+
+ # --- Helpers ---
+
+ defp date_to_datetime(%Date{} = date) do
+ date |> DateTime.new!(~T[00:00:00], "Etc/UTC")
+ end
+
+ defp date_to_datetime(nil), do: DateTime.from_unix!(0)
+
+ defp duration_text(nil), do: nil
+ defp duration_text(mins) when mins < 60, do: "#{mins} min"
+ defp duration_text(mins), do: "#{div(mins, 60)}h #{rem(mins, 60)}m"
+
+ defp strip_html(nil), do: ""
+
+ defp strip_html(html) do
+ html
+ |> String.replace(~r/<[^>]+>/, " ")
+ |> String.replace(~r/\s+/, " ")
+ |> String.trim()
+ end
+
+ defp truncate(text, max_length) when byte_size(text) <= max_length, do: text
+
+ defp truncate(text, max_length) do
+ String.slice(text, 0, max_length) <> "..."
+ end
+end
diff --git a/lib/kith/contacts/phone_formatter.ex b/lib/kith/contacts/phone_formatter.ex
index efdd7c4..1532732 100644
--- a/lib/kith/contacts/phone_formatter.ex
+++ b/lib/kith/contacts/phone_formatter.ex
@@ -30,9 +30,9 @@ defmodule Kith.Contacts.PhoneFormatter do
has_plus ->
{:ok, "+" <> digits}
- # US/Canada: bare 10-digit number
+ # Bare 10-digit number — could be many countries, store as-is
String.length(digits) == 10 ->
- {:ok, "+1" <> digits}
+ {:ok, digits}
# US/Canada: 11-digit starting with 1
String.length(digits) == 11 and String.starts_with?(digits, "1") ->
diff --git a/lib/kith_web/components/ui.ex b/lib/kith_web/components/ui.ex
index f9d7d75..f40ddec 100644
--- a/lib/kith_web/components/ui.ex
+++ b/lib/kith_web/components/ui.ex
@@ -1085,4 +1085,214 @@ defmodule KithWeb.UI do
def translate_errors(errors, field) when is_list(errors) do
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
end
+
+ # ==========================================================================
+ # Timeline Components
+ # ==========================================================================
+
+ @type_colors %{
+ note: %{dot: "bg-amber-600", badge_bg: "bg-amber-50", badge_text: "text-amber-800"},
+ call: %{dot: "bg-blue-500", badge_bg: "bg-blue-50", badge_text: "text-blue-800"},
+ life_event: %{
+ dot: "bg-emerald-500",
+ badge_bg: "bg-emerald-50",
+ badge_text: "text-emerald-800"
+ },
+ activity: %{dot: "bg-teal-500", badge_bg: "bg-teal-50", badge_text: "text-teal-800"},
+ task: %{dot: "bg-violet-500", badge_bg: "bg-violet-50", badge_text: "text-violet-800"},
+ gift: %{dot: "bg-rose-500", badge_bg: "bg-rose-50", badge_text: "text-rose-800"},
+ photo: %{dot: "bg-indigo-500", badge_bg: "bg-indigo-50", badge_text: "text-indigo-800"},
+ conversation: %{dot: "bg-pink-500", badge_bg: "bg-pink-50", badge_text: "text-pink-800"}
+ }
+
+ @doc """
+ Renders a colored dot for the activity timeline.
+
+ ## Attributes
+
+ * `:type` - atom, the entry type (e.g. `:note`, `:call`)
+ """
+ attr :type, :atom, required: true
+
+ def timeline_dot(assigns) do
+ colors = Map.get(@type_colors, assigns.type, %{dot: "bg-stone-400"})
+ assigns = assign(assigns, :dot_class, colors.dot)
+
+ ~H"""
+
+ """
+ end
+
+ @doc """
+ Renders a colored badge for an activity entry type.
+
+ ## Attributes
+
+ * `:type` - atom, the entry type (e.g. `:note`, `:call`)
+ """
+ attr :type, :atom, required: true
+
+ def type_badge(assigns) do
+ colors =
+ Map.get(@type_colors, assigns.type, %{
+ badge_bg: "bg-stone-100",
+ badge_text: "text-stone-600"
+ })
+
+ label = type_label(assigns.type)
+
+ assigns =
+ assign(assigns, badge_bg: colors.badge_bg, badge_text: colors.badge_text, label: label)
+
+ ~H"""
+
+ {@label}
+
+ """
+ end
+
+ defp type_label(:note), do: "Note"
+ defp type_label(:call), do: "Call"
+ defp type_label(:life_event), do: "Life Event"
+ defp type_label(:activity), do: "Activity"
+ defp type_label(:task), do: "Task"
+ defp type_label(:gift), do: "Gift"
+ defp type_label(:photo), do: "Photo"
+ defp type_label(:conversation), do: "Conversation"
+ defp type_label(_), do: "Entry"
+
+ @doc """
+ Renders a multi-select filter dropdown for the activity stream.
+
+ Uses Alpine.js for open/close state and selection management.
+
+ ## Attributes
+
+ * `:active_filters` - MapSet of active filter type atoms
+ * `:all_types` - list of all available type atoms
+ """
+ attr :active_filters, :any, required: true
+ attr :all_types, :list, required: true
+ attr :target, :any, default: nil
+
+ def filter_dropdown(assigns) do
+ assigns = assign(assigns, :type_colors, @type_colors)
+
+ ~H"""
+
+
+ <.icon name="hero-funnel-mini" class="size-3 opacity-50" /> Filter
+ 0}
+ class="bg-[var(--color-accent)] text-white text-[10px] rounded-full size-4 inline-flex items-center justify-center"
+ >
+ {MapSet.size(@active_filters)}
+
+
+
+ <%!-- Dropdown panel --%>
+
+
+
+
+ ✓
+
+ <.type_badge type={type} />
+
+
+
0}
+ class="border-t border-[var(--color-border-subtle)] p-1"
+ >
+
+ Clear all
+
+
+
+
+ """
+ end
+
+ @doc """
+ Renders removable filter chips for active filters.
+
+ ## Attributes
+
+ * `:active_filters` - MapSet of active filter type atoms
+ """
+ attr :active_filters, :any, required: true
+ attr :target, :any, default: nil
+
+ def filter_chips(assigns) do
+ ~H"""
+ 0} class="flex flex-wrap items-center gap-1.5">
+
+ <.type_badge type={type} />
+
+ ×
+
+
+
+ Clear all
+
+
+ """
+ end
end
diff --git a/lib/kith_web/live/contact_live/about_component.ex b/lib/kith_web/live/contact_live/about_component.ex
new file mode 100644
index 0000000..267ce77
--- /dev/null
+++ b/lib/kith_web/live/contact_live/about_component.ex
@@ -0,0 +1,321 @@
+defmodule KithWeb.ContactLive.AboutComponent do
+ use KithWeb, :live_component
+
+ alias Kith.Contacts
+
+ @impl true
+ def mount(socket) do
+ {:ok,
+ socket
+ |> assign(:editing, false)
+ |> assign(:contact_search, "")
+ |> assign(:contact_results, [])
+ |> assign(:selected_contact_id, nil)}
+ end
+
+ @impl true
+ def update(assigns, socket) do
+ {:ok, socket |> assign(assigns)}
+ end
+
+ @impl true
+ def handle_event("edit", _params, socket) do
+ selected_id = socket.assigns.contact.first_met_through_id
+
+ {:noreply,
+ socket
+ |> assign(:editing, true)
+ |> assign(:contact_search, "")
+ |> assign(:contact_results, [])
+ |> assign(:selected_contact_id, selected_id && to_string(selected_id))}
+ end
+
+ def handle_event("cancel", _params, socket) do
+ {:noreply,
+ socket
+ |> assign(:editing, false)
+ |> assign(:contact_search, "")
+ |> assign(:contact_results, [])}
+ end
+
+ def handle_event("search-contacts", %{"value" => query}, socket) do
+ results =
+ if String.length(query) >= 2 do
+ socket.assigns.account_id
+ |> Contacts.search_contacts(query)
+ |> Enum.reject(&(&1.id == socket.assigns.contact.id))
+ |> Enum.take(8)
+ else
+ []
+ end
+
+ {:noreply,
+ socket
+ |> assign(:contact_search, query)
+ |> assign(:contact_results, results)}
+ end
+
+ def handle_event("select-contact", %{"id" => id}, socket) do
+ {:noreply,
+ socket
+ |> assign(:selected_contact_id, id)
+ |> assign(:contact_search, "")
+ |> assign(:contact_results, [])}
+ end
+
+ def handle_event("clear-contact", _params, socket) do
+ {:noreply, assign(socket, :selected_contact_id, nil)}
+ end
+
+ def handle_event("save", %{"contact" => params}, socket) do
+ contact = socket.assigns.contact
+
+ params =
+ Map.put(params, "first_met_through_id", socket.assigns.selected_contact_id || "")
+
+ case Contacts.update_contact(contact, params) do
+ {:ok, updated} ->
+ updated = Kith.Repo.preload(updated, [:first_met_through], force: true)
+ send(self(), {:contact_updated, updated})
+
+ {:noreply,
+ socket
+ |> assign(:contact, updated)
+ |> assign(:editing, false)
+ |> put_flash(:info, "Updated.")}
+
+ {:error, _changeset} ->
+ {:noreply, put_flash(socket, :error, "Could not save changes.")}
+ end
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
+ About
+
+ Edit
+
+
+
+ <%= if @editing do %>
+ <.form for={%{}} phx-submit="save" phx-target={@myself} class="mt-2 space-y-2.5">
+
+
+ About
+
+
+
+
+
+
+ How we met
+
+
+
+
+
+ When
+
+
+
+
+
+
+ Where
+
+
+
+
+
+
+ Through
+
+ <%= if @selected_contact_id do %>
+ <.selected_contact_badge
+ contact={find_selected_contact(@contact, @selected_contact_id)}
+ myself={@myself}
+ />
+ <% else %>
+
+ <%= if @contact_results != [] do %>
+
+ <%= for c <- @contact_results do %>
+
+
+ {c.display_name}
+
+ <% end %>
+
+ <% end %>
+ <% end %>
+
+
+
+
+ Additional info
+
+
+
+
+
+
+ Save
+
+
+ Cancel
+
+
+
+ <% else %>
+
+ {@contact.description}
+
+
+ No description added.
+
+
+
How we met
+ <%= if has_first_met_data?(@contact) do %>
+
+
+
When
+
+
+
+
+
+
Where
+ {@contact.first_met_where}
+
+
+
Through
+
+ <.link
+ navigate={~p"/contacts/#{@contact.first_met_through.id}"}
+ class="text-[var(--color-accent)] hover:underline"
+ >
+ {@contact.first_met_through.display_name}
+
+
+
+
+
+ {@contact.first_met_additional_info}
+
+ <% else %>
+
Not recorded yet.
+ <% end %>
+
+ <% end %>
+
+ """
+ end
+
+ defp selected_contact_badge(assigns) do
+ ~H"""
+
+ <%= if @contact do %>
+
+
+ {@contact.display_name}
+
+ <% else %>
+ Unknown contact
+ <% end %>
+
+ <.icon name="hero-x-mark" class="size-3.5" />
+
+
+ """
+ end
+
+ defp find_selected_contact(contact, selected_id) do
+ id = if is_binary(selected_id), do: String.to_integer(selected_id), else: selected_id
+
+ if contact.first_met_through && contact.first_met_through.id == id do
+ contact.first_met_through
+ else
+ try do
+ Contacts.get_contact!(contact.account_id, id)
+ rescue
+ Ecto.NoResultsError -> nil
+ end
+ end
+ end
+
+ defp has_first_met_data?(contact) do
+ contact.first_met_at != nil or
+ contact.first_met_where not in [nil, ""] or
+ contact.first_met_through_id != nil or
+ contact.first_met_additional_info not in [nil, ""]
+ end
+end
diff --git a/lib/kith_web/live/contact_live/activity_stream_component.ex b/lib/kith_web/live/contact_live/activity_stream_component.ex
new file mode 100644
index 0000000..8a786f9
--- /dev/null
+++ b/lib/kith_web/live/contact_live/activity_stream_component.ex
@@ -0,0 +1,1022 @@
+defmodule KithWeb.ContactLive.ActivityStreamComponent do
+ @moduledoc """
+ Unified activity timeline component that merges all entry types
+ (notes, calls, life events, activities, tasks, gifts, conversations, photos)
+ into a single chronological stream with multi-select filtering.
+ """
+ use KithWeb, :live_component
+
+ alias Kith.Activities
+ alias Kith.Contacts
+ alias Kith.Contacts.ActivityStream
+ alias Kith.Conversations
+ alias Kith.Gifts
+ alias Kith.Storage
+ alias Kith.Tasks
+
+ @impl true
+ def mount(socket) do
+ {:ok,
+ socket
+ |> assign(:entries, [])
+ |> assign(:active_filters, MapSet.new())
+ |> assign(:all_types, ActivityStream.all_types())
+ |> assign(:modal_type, nil)
+ |> assign(:modal_entry_id, nil)
+ |> assign(:modal_error, nil)
+ |> allow_upload(:photo,
+ accept: ~w(.jpg .jpeg .png .gif .webp),
+ max_entries: 5,
+ max_file_size: 10_000_000
+ )}
+ end
+
+ @impl true
+ def update(assigns, socket) do
+ socket = assign(socket, assigns)
+ {:ok, load_entries(socket)}
+ end
+
+ # --- Filter events ---
+
+ @impl true
+ def handle_event("filter-toggle", %{"type" => type_str}, socket) do
+ type = String.to_existing_atom(type_str)
+ filters = socket.assigns.active_filters
+
+ filters =
+ if MapSet.member?(filters, type),
+ do: MapSet.delete(filters, type),
+ else: MapSet.put(filters, type)
+
+ {:noreply, socket |> assign(:active_filters, filters) |> load_entries()}
+ end
+
+ def handle_event("filter-clear", _params, socket) do
+ {:noreply, socket |> assign(:active_filters, MapSet.new()) |> load_entries()}
+ end
+
+ # --- Modal events ---
+
+ def handle_event("open-entry-modal", %{"type" => type_str}, socket) do
+ type = String.to_existing_atom(type_str)
+
+ {:noreply,
+ socket
+ |> assign(:modal_type, type)
+ |> assign(:modal_entry_id, nil)
+ |> assign(:modal_error, nil)}
+ end
+
+ def handle_event("edit-entry", %{"type" => type_str, "id" => id_str}, socket) do
+ type = String.to_existing_atom(type_str)
+ id = String.to_integer(id_str)
+
+ {:noreply,
+ socket
+ |> assign(:modal_type, type)
+ |> assign(:modal_entry_id, id)
+ |> assign(:modal_error, nil)}
+ end
+
+ def handle_event("close-modal", _params, socket) do
+ {:noreply,
+ socket
+ |> assign(:modal_type, nil)
+ |> assign(:modal_entry_id, nil)
+ |> assign(:modal_error, nil)}
+ end
+
+ def handle_event("save-entry", params, socket) do
+ %{modal_type: type, modal_entry_id: entry_id} = socket.assigns
+
+ result =
+ if entry_id,
+ do: update_entry(type, entry_id, params, socket.assigns),
+ else: create_entry(type, params, socket.assigns)
+
+ case result do
+ {:ok, _} ->
+ {:noreply,
+ socket
+ |> assign(:modal_type, nil)
+ |> assign(:modal_entry_id, nil)
+ |> assign(:modal_error, nil)
+ |> load_entries()}
+
+ {:error, message} ->
+ {:noreply, assign(socket, :modal_error, message)}
+ end
+ end
+
+ def handle_event("delete-entry", %{"type" => type_str, "id" => id_str}, socket) do
+ type = String.to_existing_atom(type_str)
+ id = String.to_integer(id_str)
+
+ case delete_entry(type, id, socket.assigns) do
+ {:ok, _} ->
+ {:noreply,
+ socket
+ |> assign(:modal_type, nil)
+ |> assign(:modal_entry_id, nil)
+ |> load_entries()}
+
+ {:error, _} ->
+ {:noreply, socket}
+ end
+ end
+
+ # --- Photo upload events ---
+
+ def handle_event("validate-photo", _params, socket) do
+ {:noreply, socket}
+ end
+
+ def handle_event("upload-photo", _params, socket) do
+ contact_id = socket.assigns.contact_id
+
+ uploaded_photos =
+ consume_uploaded_entries(socket, :photo, fn %{path: path}, entry ->
+ key = "contacts/#{contact_id}/photos/#{entry.uuid}-#{entry.client_name}"
+ {:ok, _} = Storage.upload(path, key)
+
+ {:ok, photo} =
+ Contacts.create_photo(
+ Contacts.get_contact!(socket.assigns.account_id, contact_id),
+ %{
+ file_name: entry.client_name,
+ storage_key: key,
+ file_size: entry.client_size,
+ content_type: entry.client_type
+ }
+ )
+
+ {:ok, photo}
+ end)
+
+ if uploaded_photos != [] do
+ {:noreply,
+ socket
+ |> assign(:modal_type, nil)
+ |> load_entries()
+ |> put_flash(:info, "#{length(uploaded_photos)} photo(s) uploaded.")}
+ else
+ {:noreply, socket}
+ end
+ end
+
+ def handle_event("cancel-photo-upload", %{"ref" => ref}, socket) do
+ {:noreply, cancel_upload(socket, :photo, ref)}
+ end
+
+ # --- CRUD helpers ---
+
+ defp create_entry(:note, %{"entry" => params}, assigns) do
+ contact = Contacts.get_contact!(assigns.account_id, assigns.contact_id)
+
+ Contacts.create_note(contact, assigns.current_user_id, params)
+ |> normalize_result()
+ end
+
+ defp create_entry(:call, %{"entry" => params}, assigns) do
+ contact = Contacts.get_contact!(assigns.account_id, assigns.contact_id)
+ Activities.create_call(contact, params) |> normalize_result()
+ end
+
+ defp create_entry(:life_event, %{"entry" => params}, assigns) do
+ contact = Contacts.get_contact!(assigns.account_id, assigns.contact_id)
+ Activities.create_life_event(contact, params) |> normalize_result()
+ end
+
+ defp create_entry(:task, %{"entry" => params}, assigns) do
+ params = Map.put(params, "contact_id", assigns.contact_id)
+ Tasks.create_task(assigns.account_id, assigns.current_user_id, params) |> normalize_result()
+ end
+
+ defp create_entry(:gift, %{"entry" => params}, assigns) do
+ params = Map.put(params, "contact_id", assigns.contact_id)
+ Gifts.create_gift(assigns.account_id, assigns.current_user_id, params) |> normalize_result()
+ end
+
+ defp create_entry(:conversation, %{"entry" => params}, assigns) do
+ params = Map.put(params, "contact_id", assigns.contact_id)
+
+ Conversations.create_conversation(assigns.account_id, assigns.current_user_id, params)
+ |> normalize_result()
+ end
+
+ defp create_entry(:activity, %{"entry" => params}, assigns) do
+ Activities.create_activity(assigns.account_id, params, [assigns.contact_id])
+ |> normalize_result()
+ end
+
+ defp create_entry(_, _, _), do: {:error, "Unsupported type"}
+
+ defp update_entry(:note, id, %{"entry" => params}, assigns) do
+ note = Contacts.get_note!(assigns.account_id, id, assigns.current_user_id)
+ Contacts.update_note(note, params) |> normalize_result()
+ end
+
+ defp update_entry(:call, id, %{"entry" => params}, assigns) do
+ call = Activities.get_call!(assigns.account_id, id)
+ Activities.update_call(call, params) |> normalize_result()
+ end
+
+ defp update_entry(:task, id, %{"entry" => params}, assigns) do
+ task = Tasks.get_task!(assigns.account_id, id)
+ Tasks.update_task(task, params) |> normalize_result()
+ end
+
+ defp update_entry(:gift, id, %{"entry" => params}, assigns) do
+ gift = Gifts.get_gift!(assigns.account_id, id)
+ Gifts.update_gift(gift, params) |> normalize_result()
+ end
+
+ defp update_entry(_, _, _, _), do: {:error, "Update not supported for this type"}
+
+ defp delete_entry(:note, id, assigns) do
+ note = Contacts.get_note!(assigns.account_id, id, assigns.current_user_id)
+ Contacts.delete_note(note) |> normalize_result()
+ end
+
+ defp delete_entry(:call, id, assigns) do
+ call = Activities.get_call!(assigns.account_id, id)
+ Activities.delete_call(call) |> normalize_result()
+ end
+
+ defp delete_entry(:task, id, assigns) do
+ task = Tasks.get_task!(assigns.account_id, id)
+ Tasks.delete_task(task) |> normalize_result()
+ end
+
+ defp delete_entry(:gift, id, assigns) do
+ gift = Gifts.get_gift!(assigns.account_id, id)
+ Gifts.delete_gift(gift) |> normalize_result()
+ end
+
+ defp delete_entry(_, _, _), do: {:error, "Delete not supported for this type"}
+
+ defp normalize_result({:ok, result}), do: {:ok, result}
+ defp normalize_result({:error, %Ecto.Changeset{} = cs}), do: {:error, changeset_error(cs)}
+ defp normalize_result({:error, _, %Ecto.Changeset{} = cs, _}), do: {:error, changeset_error(cs)}
+ defp normalize_result({:error, msg}) when is_binary(msg), do: {:error, msg}
+ defp normalize_result({:error, _}), do: {:error, "Something went wrong"}
+
+ defp changeset_error(changeset) do
+ Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
+ Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
+ opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
+ end)
+ end)
+ |> Enum.map_join(", ", fn {field, errors} -> "#{field}: #{Enum.join(errors, ", ")}" end)
+ end
+
+ # --- Data loading ---
+
+ defp load_entries(socket) do
+ %{account_id: account_id, contact_id: contact_id, current_user_id: current_user_id} =
+ socket.assigns
+
+ filters = socket.assigns.active_filters
+ opts = [current_user_id: current_user_id, limit: 50]
+
+ opts =
+ if MapSet.size(filters) > 0,
+ do: Keyword.put(opts, :types, MapSet.to_list(filters)),
+ else: opts
+
+ entries = ActivityStream.list_activity(account_id, contact_id, opts)
+ assign(socket, :entries, entries)
+ end
+
+ defp photos_only?(filters) do
+ MapSet.size(filters) == 1 and MapSet.member?(filters, :photo)
+ end
+
+ # --- Render ---
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+ <%!-- Stream Header --%>
+
+
+
Activity
+
+ <.filter_dropdown
+ active_filters={@active_filters}
+ all_types={@all_types}
+ target={@myself}
+ />
+
+ + Note
+
+
+ + Call
+
+
+ + Event
+
+ <%!-- More types overflow --%>
+
+
+ <.icon name="hero-plus-mini" class="size-4" />
+
+
+
+ + {type_label(type)}
+
+
+
+
+
+
0} class="mt-2.5">
+ <.filter_chips active_filters={@active_filters} target={@myself} />
+
+
+
+ <%!-- Entry Modal --%>
+ <.modal
+ :if={@modal_type}
+ id="entry-modal"
+ show
+ on_cancel={JS.push("close-modal", target: @myself)}
+ >
+ <.entry_modal_content
+ type={@modal_type}
+ entry_id={@modal_entry_id}
+ error={@modal_error}
+ myself={@myself}
+ account_id={@account_id}
+ contact_id={@contact_id}
+ uploads={@uploads}
+ />
+
+
+ <%!-- Photos Gallery Mode --%>
+
0}
+ class="grid grid-cols-3 sm:grid-cols-4 gap-2"
+ >
+
+
+ <.icon name="hero-photo" class="size-6" />
+
+
+
+
+ <%!-- Timeline Mode --%>
+
+
+
+
+ <.icon name="hero-clock" class="size-8 text-[var(--color-text-disabled)] mx-auto mb-2" />
+
No activity yet
+
+ Add a note, log a call, or record an event to get started.
+
+
+
+
+ <.timeline_dot type={entry.type} />
+
+
+
+
+ <.relative_time_or_date datetime={entry.occurred_at} />
+
+ <.type_badge type={entry.type} />
+
+ {duration_text(entry.record.duration_mins)}
+
+
+ {entry.record.platform}
+
+
+ <%!-- Entry overflow menu --%>
+
+
+ …
+
+
+
+ <.icon name="hero-pencil-square-mini" class="size-3.5" /> Edit
+
+
+ <.icon name="hero-trash-mini" class="size-3.5" /> Delete
+
+
+
+
+
+
+
+ {entry.title}
+
+
+ {entry.title}
+
+
+
+
+ {if entry.type == :note, do: strip_html(entry.body), else: entry.body}
+
+
+
+
+ <.icon name="hero-photo" class="size-5" />
+
+
+
+
+
+ """
+ end
+
+ # --- Modal form content per type ---
+
+ defp entry_modal_content(%{type: :note} = assigns) do
+ ~H"""
+
+
{if @entry_id, do: "Edit Note", else: "New Note"}
+ <.entry_error error={@error} />
+ <.form for={%{}} phx-submit="save-entry" phx-target={@myself}>
+
+
+
+ Content
+
+
+
+
+
+
+ Private (only visible to you)
+
+
+
+ <.modal_actions myself={@myself} entry_id={@entry_id} type={:note} />
+
+
+ """
+ end
+
+ defp entry_modal_content(%{type: :call} = assigns) do
+ ~H"""
+
+
{if @entry_id, do: "Edit Call", else: "Log Call"}
+ <.entry_error error={@error} />
+ <.form for={%{}} phx-submit="save-entry" phx-target={@myself}>
+
+
+
+ When
+
+
+
+
+
+ Duration (minutes)
+
+
+
+
+
+ Notes
+
+
+
+
+ <.modal_actions myself={@myself} entry_id={@entry_id} type={:call} />
+
+
+ """
+ end
+
+ defp entry_modal_content(%{type: :life_event} = assigns) do
+ life_event_types = Kith.Repo.all(Kith.Contacts.LifeEventType)
+ assigns = assign(assigns, :life_event_types, life_event_types)
+
+ ~H"""
+
+
+ {if @entry_id, do: "Edit Life Event", else: "New Life Event"}
+
+ <.entry_error error={@error} />
+ <.form for={%{}} phx-submit="save-entry" phx-target={@myself}>
+
+
+
+ Type
+
+
+ Select type...
+ {t.name}
+
+
+
+
+ Date
+
+
+
+
+
+ Notes
+
+
+
+
+ <.modal_actions myself={@myself} entry_id={@entry_id} type={:life_event} />
+
+
+ """
+ end
+
+ defp entry_modal_content(%{type: :task} = assigns) do
+ ~H"""
+
+
{if @entry_id, do: "Edit Task", else: "New Task"}
+ <.entry_error error={@error} />
+ <.form for={%{}} phx-submit="save-entry" phx-target={@myself}>
+
+
+
+ Title
+
+
+
+
+
+ Description
+
+
+
+
+
+ Due date
+
+
+
+
+ <.modal_actions myself={@myself} entry_id={@entry_id} type={:task} />
+
+
+ """
+ end
+
+ defp entry_modal_content(%{type: :gift} = assigns) do
+ ~H"""
+
+
{if @entry_id, do: "Edit Gift", else: "New Gift"}
+ <.entry_error error={@error} />
+ <.form for={%{}} phx-submit="save-entry" phx-target={@myself}>
+
+
+
+ Name
+
+
+
+
+
+ Direction
+
+
+ Given
+ Received
+
+
+
+
+ Description
+
+
+
+
+ <.modal_actions myself={@myself} entry_id={@entry_id} type={:gift} />
+
+
+ """
+ end
+
+ defp entry_modal_content(%{type: :conversation} = assigns) do
+ ~H"""
+
+
+ {if @entry_id, do: "Edit Conversation", else: "New Conversation"}
+
+ <.entry_error error={@error} />
+ <.form for={%{}} phx-submit="save-entry" phx-target={@myself}>
+
+
+
+ Subject
+
+
+
+
+
+ Platform
+
+
+ SMS
+ WhatsApp
+ Telegram
+ Email
+ Instagram
+ Messenger
+ Signal
+ Other
+
+
+
+ <.modal_actions myself={@myself} entry_id={@entry_id} type={:conversation} />
+
+
+ """
+ end
+
+ defp entry_modal_content(%{type: :activity} = assigns) do
+ ~H"""
+
+
+ {if @entry_id, do: "Edit Activity", else: "New Activity"}
+
+ <.entry_error error={@error} />
+ <.form for={%{}} phx-submit="save-entry" phx-target={@myself}>
+
+
+
+ Title
+
+
+
+
+
+ When
+
+
+
+
+
+ Description
+
+
+
+
+ <.modal_actions myself={@myself} entry_id={@entry_id} type={:activity} />
+
+
+ """
+ end
+
+ defp entry_modal_content(%{type: :photo} = assigns) do
+ ~H"""
+
+
Upload Photos
+ <.form
+ for={%{}}
+ id="photo-upload-form"
+ phx-submit="upload-photo"
+ phx-change="validate-photo"
+ phx-target={@myself}
+ class="space-y-4"
+ >
+
+ <.icon name="hero-photo" class="size-8 text-[var(--color-text-disabled)] mx-auto mb-2" />
+
+ Select up to 5 photos (max 10 MB each)
+
+ <.live_file_input
+ upload={@uploads.photo}
+ class="block w-full text-sm text-[var(--color-text-secondary)] file:mr-4 file:py-2 file:px-4 file:rounded-[var(--radius-md)] file:border-0 file:text-sm file:font-medium file:bg-[var(--color-accent)] file:text-[var(--color-accent-foreground)] file:cursor-pointer hover:file:bg-[var(--color-accent-hover)]"
+ />
+
+
+ <%= for entry <- @uploads.photo.entries do %>
+
+
+
+ {entry.client_name}
+
+ <.icon name="hero-x-mark" class="size-4" />
+
+
+
+
+
+ <%= for err <- upload_errors(@uploads.photo, entry) do %>
+
{photo_error_to_string(err)}
+ <% end %>
+ <% end %>
+
+ <%= for err <- upload_errors(@uploads.photo) do %>
+
{photo_error_to_string(err)}
+ <% end %>
+
+
+
+ <.icon name="hero-arrow-up-tray" class="size-4" /> Upload
+
+
+ Cancel
+
+
+
+
+ """
+ end
+
+ defp entry_modal_content(assigns) do
+ ~H"""
+
+
Unsupported type
+
+ This entry type cannot be edited from here.
+
+
+ """
+ end
+
+ defp entry_error(assigns) do
+ ~H"""
+
+ {@error}
+
+ """
+ end
+
+ defp modal_actions(assigns) do
+ ~H"""
+
+
+ Delete
+
+
+
+
+ Cancel
+
+
+ Save
+
+
+
+ """
+ end
+
+ # --- Helpers ---
+
+ defp relative_time_or_date(assigns) do
+ today = Date.utc_today()
+ date = DateTime.to_date(assigns.datetime)
+ diff = Date.diff(today, date)
+
+ label =
+ cond do
+ diff == 0 -> "Today"
+ diff == 1 -> "Yesterday"
+ diff < 7 -> "#{diff} days ago"
+ true -> Calendar.strftime(assigns.datetime, "%b %d, %Y")
+ end
+
+ assigns = assign(assigns, :label, label)
+
+ ~H"""
+ {@label}
+ """
+ end
+
+ defp type_label(:note), do: "Note"
+ defp type_label(:call), do: "Call"
+ defp type_label(:life_event), do: "Life Event"
+ defp type_label(:activity), do: "Activity"
+ defp type_label(:task), do: "Task"
+ defp type_label(:gift), do: "Gift"
+ defp type_label(:photo), do: "Photo"
+ defp type_label(:conversation), do: "Conversation"
+ defp type_label(_), do: "Entry"
+
+ defp duration_text(mins) when mins < 60, do: "#{mins} min"
+ defp duration_text(mins), do: "#{div(mins, 60)}h #{rem(mins, 60)}m"
+
+ defp strip_html(nil), do: ""
+
+ defp strip_html(html) do
+ html
+ |> String.replace(~r/<[^>]+>/, " ")
+ |> String.replace(~r/\s+/, " ")
+ |> String.trim()
+ end
+
+ defp photo_error_to_string(:too_large), do: "File too large (max 10 MB)"
+ defp photo_error_to_string(:too_many_files), do: "Too many files (max 5)"
+ defp photo_error_to_string(:not_accepted), do: "Invalid file type"
+ defp photo_error_to_string(err), do: to_string(err)
+end
diff --git a/lib/kith_web/live/contact_live/contact_fields_component.ex b/lib/kith_web/live/contact_live/contact_fields_component.ex
index fda4ad8..d1f8349 100644
--- a/lib/kith_web/live/contact_live/contact_fields_component.ex
+++ b/lib/kith_web/live/contact_live/contact_fields_component.ex
@@ -119,15 +119,15 @@ defmodule KithWeb.ContactLive.ContactFieldsComponent do
def render(assigns) do
~H"""
-
-
Contact Info
+
+ Contact
<%= if @can_edit do %>
- <.icon name="hero-plus" class="size-4" />
+ + Add
<% end %>
@@ -197,22 +197,7 @@ defmodule KithWeb.ContactLive.ContactFieldsComponent do
<% end %>
<%= if @fields == [] and not @show_form do %>
-
- <:actions :if={@can_edit}>
-
- Add Info
-
-
-
+
No contact info added.
<% end %>
@@ -269,39 +254,32 @@ defmodule KithWeb.ContactLive.ContactFieldsComponent do
<% else %>
-
-
- <.icon name={field_icon(field)} class="size-5 text-[var(--color-text-disabled)]" />
-
-
- <%= if link = field_link(field) do %>
-
- {display_value(field, @phone_format)}
-
- <% else %>
-
{display_value(field, @phone_format)}
- <% end %>
- <%= if field.label do %>
-
- {field.label}
-
- <% end %>
-
-
- {field.contact_field_type.name}
-
-
-
+
+
+ <.icon name={field_icon(field)} class="size-4" />
+
+
+ <%= if link = field_link(field) do %>
+
+ {display_value(field, @phone_format)}
+
+ <% else %>
+ {display_value(field, @phone_format)}
+ <% end %>
+
+
+ {field.label}
+
+
+ {field.contact_field_type.name}
+
<%= if @can_edit do %>
-
+
Edit
@@ -310,9 +288,9 @@ defmodule KithWeb.ContactLive.ContactFieldsComponent do
phx-value-id={field.id}
phx-target={@myself}
data-confirm="Delete this field?"
- class="text-[var(--color-error)] hover:text-[var(--color-error)] transition-colors"
+ class="text-[var(--color-error)] cursor-pointer"
>
- Delete
+ Del
<% end %>
diff --git a/lib/kith_web/live/contact_live/edit.ex b/lib/kith_web/live/contact_live/edit.ex
index 12a9ffb..5bb2df8 100644
--- a/lib/kith_web/live/contact_live/edit.ex
+++ b/lib/kith_web/live/contact_live/edit.ex
@@ -123,7 +123,7 @@ defmodule KithWeb.ContactLive.Edit do
case Contacts.update_contact(contact, contact_params) do
{:ok, updated_contact} ->
- Kith.AuditLogs.log_event(account_id, user, "Contact updated",
+ Kith.AuditLogs.log_event(account_id, user, :contact_updated,
contact_id: updated_contact.id,
contact_name: updated_contact.display_name
)
diff --git a/lib/kith_web/live/contact_live/relationships_component.ex b/lib/kith_web/live/contact_live/relationships_component.ex
index 73e851c..10cd42f 100644
--- a/lib/kith_web/live/contact_live/relationships_component.ex
+++ b/lib/kith_web/live/contact_live/relationships_component.ex
@@ -110,15 +110,15 @@ defmodule KithWeb.ContactLive.RelationshipsComponent do
def render(assigns) do
~H"""
-
-
Relationships
+
+ Relationships
<%= if @can_edit do %>
- <.icon name="hero-plus" class="size-4" />
+ + Add
<% end %>
@@ -200,41 +200,24 @@ defmodule KithWeb.ContactLive.RelationshipsComponent do
<% end %>
<%= if @relationships == [] and not @show_form do %>
-
- <:actions :if={@can_edit}>
-
- Add Relationship
-
-
-
+
No relationships added.
<% end %>
-
+
<%= for rel <- @relationships do %>
-
-
- <.icon name="hero-users" class="size-5 text-[var(--color-text-disabled)]" />
+
+ <.link
+ navigate={~p"/contacts/#{rel.related_contact.id}"}
+ class="flex items-center gap-2.5 hover:opacity-80 transition-opacity"
+ >
+
- <.link
- navigate={~p"/contacts/#{rel.related_contact.id}"}
- class="text-[var(--color-accent)] hover:text-[var(--color-accent-hover)] transition-colors font-medium"
- >
+
{rel.related_contact.display_name}
-
-
- {rel.label}
-
+
+
{rel.label}
-
+
<%= if @can_edit do %>
<%= if @confirming_delete_id == rel.relationship.id do %>
diff --git a/lib/kith_web/live/contact_live/reminders_component.ex b/lib/kith_web/live/contact_live/reminders_component.ex
index 227a300..231bc9e 100644
--- a/lib/kith_web/live/contact_live/reminders_component.ex
+++ b/lib/kith_web/live/contact_live/reminders_component.ex
@@ -3,9 +3,28 @@ defmodule KithWeb.ContactLive.RemindersComponent do
alias Kith.Reminders
+ @type_options [
+ {"One-time", "one_time"},
+ {"Recurring", "recurring"},
+ {"Stay in touch", "stay_in_touch"}
+ ]
+
+ @frequency_options [
+ {"Weekly", "weekly"},
+ {"Every 2 weeks", "biweekly"},
+ {"Monthly", "monthly"},
+ {"Every 3 months", "3months"},
+ {"Every 6 months", "6months"},
+ {"Annually", "annually"}
+ ]
+
@impl true
def mount(socket) do
- {:ok, assign(socket, :reminders, [])}
+ {:ok,
+ socket
+ |> assign(:reminders, [])
+ |> assign(:show_form, false)
+ |> assign(:form_type, "one_time")}
end
@impl true
@@ -18,6 +37,52 @@ defmodule KithWeb.ContactLive.RemindersComponent do
|> assign(:reminders, reminders)}
end
+ @impl true
+ def handle_event("show-form", _params, socket) do
+ {:noreply, socket |> assign(:show_form, true) |> assign(:form_type, "one_time")}
+ end
+
+ def handle_event("cancel-form", _params, socket) do
+ {:noreply, assign(socket, :show_form, false)}
+ end
+
+ def handle_event("type-changed", %{"reminder" => %{"type" => type}}, socket) do
+ {:noreply, assign(socket, :form_type, type)}
+ end
+
+ def handle_event("save", %{"reminder" => params}, socket) do
+ attrs =
+ params
+ |> Map.put("contact_id", socket.assigns.contact_id)
+ |> Map.put("account_id", socket.assigns.account_id)
+ |> Map.put("creator_id", socket.assigns.creator_id)
+
+ case Reminders.create_reminder(socket.assigns.account_id, socket.assigns.creator_id, attrs) do
+ {:ok, _} ->
+ reminders = Reminders.list_reminders(socket.assigns.account_id, socket.assigns.contact_id)
+
+ {:noreply,
+ socket
+ |> assign(:reminders, reminders)
+ |> assign(:show_form, false)
+ |> put_flash(:info, "Reminder added.")}
+
+ {:error, changeset} ->
+ {:noreply, put_flash(socket, :error, format_errors(changeset))}
+ end
+ end
+
+ def handle_event("delete", %{"id" => id}, socket) do
+ reminder = Reminders.get_reminder!(socket.assigns.account_id, String.to_integer(id))
+ {:ok, _} = Reminders.delete_reminder(reminder)
+ reminders = Reminders.list_reminders(socket.assigns.account_id, socket.assigns.contact_id)
+
+ {:noreply,
+ socket
+ |> assign(:reminders, reminders)
+ |> put_flash(:info, "Reminder deleted.")}
+ end
+
defp type_label("birthday"), do: "Birthday"
defp type_label("stay_in_touch"), do: "Stay in touch"
defp type_label("one_time"), do: "One-time"
@@ -33,12 +98,116 @@ defmodule KithWeb.ContactLive.RemindersComponent do
defp frequency_label("annually"), do: "Annually"
defp frequency_label(other), do: other
+ defp format_errors(changeset) do
+ Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
+ Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
+ opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
+ end)
+ end)
+ |> Enum.map_join(", ", fn {field, msgs} -> "#{field} #{Enum.join(msgs, ", ")}" end)
+ end
+
+ defp needs_frequency?(type) when type in ["recurring", "stay_in_touch"], do: true
+ defp needs_frequency?(_), do: false
+
@impl true
def render(assigns) do
+ assigns =
+ assigns
+ |> assign(:type_options, @type_options)
+ |> assign(:frequency_options, @frequency_options)
+
~H"""
-
Reminders
- <%= if @reminders == [] do %>
+
+
Reminders
+
+ Add
+
+
+
+ <%= if @show_form do %>
+
+ <.form for={%{}} phx-submit="save" phx-change="type-changed" phx-target={@myself}>
+
+
+
+ Type
+
+
+ <%= for {label, value} <- @type_options do %>
+ {label}
+ <% end %>
+
+
+
+
+ Title (optional)
+
+
+
+
+
+ Frequency
+
+
+ <%= for {label, value} <- @frequency_options do %>
+ {label}
+ <% end %>
+
+
+
+
+ Next reminder date
+
+
+
+
+
+
+ Save
+
+
+ Cancel
+
+
+
+
+ <% end %>
+
+ <%= if @reminders == [] and not @show_form do %>
<.icon name="hero-bell" class="size-3.5 text-[var(--color-text-disabled)]" />
No reminders set.
@@ -46,7 +215,7 @@ defmodule KithWeb.ContactLive.RemindersComponent do
<% else %>
<%= for reminder <- @reminders do %>
-
+
<.icon
name={if reminder.active, do: "hero-bell", else: "hero-bell-slash"}
class={[
@@ -55,18 +224,31 @@ defmodule KithWeb.ContactLive.RemindersComponent do
!reminder.active && "text-[var(--color-text-disabled)]"
]}
/>
-
+
{reminder.title || type_label(reminder.type)}
{type_label(reminder.type)}
- · {frequency_label(reminder.frequency)}
+
+ · {frequency_label(reminder.frequency)}
+
Next: <.date_display date={reminder.next_reminder_date} />
+
+ <.icon name="hero-x-mark" class="size-3.5" />
+
<% end %>
diff --git a/lib/kith_web/live/contact_live/show.ex b/lib/kith_web/live/contact_live/show.ex
index c8f87c0..6638b6e 100644
--- a/lib/kith_web/live/contact_live/show.ex
+++ b/lib/kith_web/live/contact_live/show.ex
@@ -1,7 +1,6 @@
defmodule KithWeb.ContactLive.Show do
@moduledoc """
- Contact profile page with two-column layout: sidebar metadata + tabbed content.
- Each tab is a Level 2 LiveComponent that loads its own data.
+ Contact profile page with hero banner, grouped sidebar, and unified activity stream.
"""
use KithWeb, :live_view
@@ -10,19 +9,26 @@ defmodule KithWeb.ContactLive.Show do
alias Kith.Contacts
alias Kith.DuplicateDetection
- @tabs ~w(notes life_events activities calls tasks gifts conversations photos)a
-
@impl true
def mount(_params, _session, socket) do
# No DB queries in mount — mount is called twice (HTTP + WebSocket).
{:ok,
socket
|> assign(:contact, nil)
- |> assign(:active_tab, :notes)
|> assign(:tags, [])
|> assign(:tag_search, "")
|> assign(:show_tag_dropdown, false)
- |> assign(:duplicate_candidates, [])}
+ |> assign(:show_more_drawer, false)
+ |> assign(:mobile_sidebar_tab, "basic-info")
+ |> assign(:duplicate_candidates, [])
+ |> assign(:next_reminder, nil)
+ |> assign(:show_avatar_picker, false)
+ |> assign(:avatar_photos, [])
+ |> allow_upload(:avatar,
+ accept: ~w(.jpg .jpeg .png .gif .webp),
+ max_entries: 1,
+ max_file_size: 10_000_000
+ )}
end
@impl true
@@ -34,6 +40,8 @@ defmodule KithWeb.ContactLive.Show do
Contacts.get_contact!(account_id, String.to_integer(id))
|> Kith.Repo.preload([:tags, :gender, :first_met_through])
+ next_reminder = compute_next_birthday_badge(contact)
+
{:noreply,
socket
|> assign(:page_title, contact.display_name)
@@ -41,6 +49,7 @@ defmodule KithWeb.ContactLive.Show do
|> assign(:current_user_id, user_id)
|> assign(:contact, contact)
|> assign(:tags, Contacts.list_tags(account_id))
+ |> assign(:next_reminder, next_reminder)
|> assign(
:duplicate_candidates,
DuplicateDetection.pending_candidates_for_contact(account_id, contact.id)
@@ -110,14 +119,12 @@ defmodule KithWeb.ContactLive.Show do
|> push_navigate(to: ~p"/contacts")}
end
- def handle_event("switch-tab", %{"tab" => tab}, socket) do
- tab_atom = String.to_existing_atom(tab)
+ def handle_event("toggle-more-drawer", _params, socket) do
+ {:noreply, update(socket, :show_more_drawer, &(!&1))}
+ end
- if tab_atom in @tabs do
- {:noreply, assign(socket, :active_tab, tab_atom)}
- else
- {:noreply, socket}
- end
+ def handle_event("switch-mobile-tab", %{"tab" => tab}, socket) do
+ {:noreply, assign(socket, :mobile_sidebar_tab, tab)}
end
def handle_event("toggle-tag-dropdown", _params, socket) do
@@ -145,6 +152,62 @@ defmodule KithWeb.ContactLive.Show do
|> assign(:tag_search, "")}
end
+ def handle_event("toggle-avatar-picker", _params, socket) do
+ show = !socket.assigns.show_avatar_picker
+ photos = if show, do: Contacts.list_photos(socket.assigns.contact.id), else: []
+ {:noreply, socket |> assign(:show_avatar_picker, show) |> assign(:avatar_photos, photos)}
+ end
+
+ def handle_event("pick-avatar-photo", %{"id" => id}, socket) do
+ contact = socket.assigns.contact
+ photo = Contacts.get_photo!(contact.account_id, String.to_integer(id))
+ {:ok, updated_contact} = Contacts.set_avatar(contact, photo)
+
+ updated_contact =
+ Kith.Repo.preload(updated_contact, [:tags, :gender, :first_met_through], force: true)
+
+ {:noreply,
+ socket
+ |> assign(:contact, updated_contact)
+ |> assign(:show_avatar_picker, false)
+ |> put_flash(:info, "Avatar updated.")}
+ end
+
+ def handle_event("validate-avatar", _params, socket) do
+ {:noreply, socket}
+ end
+
+ def handle_event("upload-avatar", _params, socket) do
+ contact = socket.assigns.contact
+
+ [photo] =
+ consume_uploaded_entries(socket, :avatar, fn %{path: path}, entry ->
+ key = "contacts/#{contact.id}/photos/#{entry.uuid}-#{entry.client_name}"
+ {:ok, _} = Kith.Storage.upload(path, key)
+
+ {:ok, photo} =
+ Contacts.create_photo(contact, %{
+ file_name: entry.client_name,
+ storage_key: key,
+ file_size: entry.client_size,
+ content_type: entry.client_type
+ })
+
+ {:ok, photo}
+ end)
+
+ {:ok, updated_contact} = Contacts.set_avatar(contact, photo)
+
+ updated_contact =
+ Kith.Repo.preload(updated_contact, [:tags, :gender, :first_met_through], force: true)
+
+ {:noreply,
+ socket
+ |> assign(:contact, updated_contact)
+ |> assign(:show_avatar_picker, false)
+ |> put_flash(:info, "Avatar updated.")}
+ end
+
def handle_event("remove-tag", %{"tag_id" => tag_id}, socket) do
account_id = socket.assigns.account_id
contact = socket.assigns.contact
@@ -160,11 +223,16 @@ defmodule KithWeb.ContactLive.Show do
@impl true
def handle_info({:avatar_updated, updated_contact}, socket) do
- {:noreply, assign(socket, :contact, updated_contact)}
+ updated_contact =
+ Kith.Repo.preload(updated_contact, [:tags, :gender, :first_met_through], force: true)
+
+ {:noreply,
+ socket
+ |> assign(:contact, updated_contact)
+ |> assign(:avatar_photos, Contacts.list_photos(updated_contact.id))}
end
- @impl true
- def handle_info({:first_met_updated, updated_contact}, socket) do
+ def handle_info({:contact_updated, updated_contact}, socket) do
contact =
Kith.Repo.preload(updated_contact, [:tags, :gender, :first_met_through], force: true)
@@ -188,14 +256,32 @@ defmodule KithWeb.ContactLive.Show do
Kith.Policy.can?(assigns.current_scope.user, action, resource)
end
- defp tab_label(:notes), do: "Notes"
- defp tab_label(:life_events), do: "Life Events"
- defp tab_label(:activities), do: "Activities"
- defp tab_label(:calls), do: "Calls"
- defp tab_label(:tasks), do: "Tasks"
- defp tab_label(:gifts), do: "Gifts"
- defp tab_label(:conversations), do: "Conversations"
- defp tab_label(:photos), do: "Photos"
+ defp compute_next_birthday_badge(contact) do
+ case contact.birthdate do
+ nil ->
+ nil
+
+ birthdate ->
+ today = Date.utc_today()
+ this_year = Date.new!(today.year, birthdate.month, birthdate.day)
+
+ next_birthday =
+ if Date.compare(this_year, today) in [:gt, :eq],
+ do: this_year,
+ else: Date.new!(today.year + 1, birthdate.month, birthdate.day)
+
+ days = Date.diff(next_birthday, today)
+
+ label =
+ cond do
+ days == 0 -> "today"
+ days == 1 -> "tomorrow"
+ true -> "in #{days} days"
+ end
+
+ "Birthday #{label}"
+ end
+ end
defp filtered_tags(tags, contact_tags, search) do
contact_tag_ids = Enum.map(contact_tags, & &1.id) |> MapSet.new()
diff --git a/lib/kith_web/live/contact_live/show.html.heex b/lib/kith_web/live/contact_live/show.html.heex
index 2f37bc2..98b6513 100644
--- a/lib/kith_web/live/contact_live/show.html.heex
+++ b/lib/kith_web/live/contact_live/show.html.heex
@@ -11,10 +11,11 @@
navigate={~p"/contacts"}
class="inline-flex items-center gap-1.5 text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-accent)] transition-colors"
>
- <.icon name="hero-arrow-left" class="size-4 rtl:rotate-180" /> Back to Contacts
+ <.icon name="hero-arrow-left" class="size-4 rtl:rotate-180" /> Contacts
+ <%!-- Duplicate alert --%>
-
- <%!-- Sidebar --%>
-
- <%!-- Main info card --%>
-
-
-
-
-
-
- {@contact.display_name}
-
-
- <.icon
- name={if @contact.favorite, do: "hero-star-solid", else: "hero-star"}
- class={["size-5", @contact.favorite && "text-[var(--color-warning)]"]}
+ <%!-- ========== HERO BANNER ========== --%>
+
+
+ <%!-- Avatar with change overlay --%>
+
+
+ <%= if can?(assigns, :update, :contact) do %>
+
+ <.icon
+ name="hero-camera"
+ class="size-6 text-white opacity-0 group-hover:opacity-100 transition-opacity"
+ />
+
+ <% end %>
+ <%!-- Avatar picker popover --%>
+ <%= if @show_avatar_picker do %>
+
+
+ Change avatar
+
+
+ <%= if @avatar_photos != [] do %>
+
+ Or choose from photos
+
+
+ <%= for photo <- @avatar_photos do %>
+
+
+
+ <% end %>
+
+ <% end %>
+ <% end %>
+
-
- "{@contact.nickname}"
-
-
- Archived
-
-
Deceased
-
- Immich linked
-
-
+
+
+
+ {@contact.display_name}
+
+
- Immich review needed
-
+ <.icon
+ name={if @contact.favorite, do: "hero-star-solid", else: "hero-star"}
+ class={["size-5", @contact.favorite && "text-[var(--color-warning)]"]}
+ />
+
+
+ "{@contact.nickname}"
+
- <%!-- Metadata --%>
-
-
-
Gender
- {@contact.gender.name}
-
+ <%!-- Subtitle --%>
+
+ {@contact.occupation} at {@contact.company}
+ ·
+
-
-
Birthday
-
-
-
- ({age}y)
-
-
-
-
-
-
Occupation
- {@contact.occupation}
-
-
-
-
Company
- {@contact.company}
-
-
-
-
Last Contacted
-
-
-
-
-
Deceased
-
-
-
- <%= if @contact.immich_status == "linked" and @contact.immich_person_url do %>
-
- <% end %>
-
-
- <%!-- Tags --%>
-
-
- Tags
- <%= if can?(assigns, :update, :tag) do %>
-
- <.icon name="hero-plus" class="size-4" />
-
- <% end %>
-
+ <%!-- Tags + status badges --%>
+
+
+
+
+ Archived
+ Deceased
+ <%!-- Next reminder badge --%>
+
+ {@next_reminder}
+
+
+
-
-
-
- <%= if can?(assigns, :update, :tag) do %>
+ <%!-- Actions --%>
+ <%= if can?(assigns, :update, :contact) do %>
+
+
+ Edit
+
+
+
+ <.icon name="hero-ellipsis-horizontal" class="size-5" />
+
+
+ <%= if @contact.is_archived do %>
- ×
+ <.icon name="hero-archive-box-arrow-down" class="size-4" /> Unarchive
- <% end %>
-
-
-
- <%!-- Tag dropdown --%>
- <%= if @show_tag_dropdown do %>
-
-
-
+ <% else %>
-
+ <.icon name="hero-archive-box" class="size-4" /> Archive
-
+ <% end %>
+ <.link
+ navigate={~p"/contacts/#{@contact.id}/merge"}
+ class="flex w-full items-center gap-2 rounded-[var(--radius-md)] px-2.5 py-1.5 text-sm text-[var(--color-text-primary)] hover:bg-[var(--color-surface)] transition-colors"
+ >
+ <.icon name="hero-arrows-right-left" class="size-4" /> Merge
+
+
+
+ <.icon name="hero-trash" class="size-4" /> Delete
+
- <% end %>
+
+ <% end %>
+
+
- <%!-- Description --%>
- <%= if @contact.description do %>
-
-
About
-
- {@contact.description}
-
+ <%!-- ========== MOBILE SIDEBAR TABS (visible below lg) ========== --%>
+
+
+ <%!-- Horizontal tab bar --%>
+
+
+ {tab_label}
+
+
+ <%!-- Tab content --%>
+
+ <%!-- Basic Info --%>
+
+
+
+
Birthday
+
+
+
+ ({age})
+
+
+
+
+
Gender
+ {@contact.gender.name}
+
+
+
Occupation
+ {@contact.occupation}
+
+
+
Company
+ {@contact.company}
+
+
+
Last contact
+
+
+
+
+
+
+ <%!-- Contact --%>
+
+ <.live_component
+ module={KithWeb.ContactLive.ContactFieldsComponent}
+ id={"mobile-contact-fields-#{@contact.id}"}
+ contact_id={@contact.id}
+ account_id={@account_id}
+ phone_format={@current_scope.account.phone_format || "e164"}
+ can_edit={can?(assigns, :create, :contact_field)}
+ />
+
+ <.live_component
+ module={KithWeb.ContactLive.AddressesComponent}
+ id={"mobile-addresses-#{@contact.id}"}
+ contact_id={@contact.id}
+ account_id={@account_id}
+ can_edit={can?(assigns, :create, :address)}
+ />
- <% end %>
-
- <%!-- How We Met --%>
-
+
+ <%!-- People --%>
+
<.live_component
- module={KithWeb.ContactLive.FirstMetComponent}
- id={"first-met-#{@contact.id}"}
- contact={@contact}
+ module={KithWeb.ContactLive.RelationshipsComponent}
+ id={"mobile-relationships-#{@contact.id}"}
contact_id={@contact.id}
account_id={@account_id}
+ can_edit={can?(assigns, :create, :relationship)}
+ />
+
+ <%!-- About --%>
+
+ <.live_component
+ module={KithWeb.ContactLive.AboutComponent}
+ id={"mobile-about-#{@contact.id}"}
+ contact={@contact}
+ account_id={@account_id}
can_edit={can?(assigns, :update, :contact)}
/>
-
+ <%!-- Tags --%>
+
+ <%!-- More --%>
+
+ <.live_component
+ module={KithWeb.ContactLive.RemindersComponent}
+ id={"mobile-reminders-#{@contact.id}"}
+ contact_id={@contact.id}
+ account_id={@account_id}
+ creator_id={@current_user_id}
+ can_edit={can?(assigns, :create, :reminder)}
+ />
+ <.live_component
+ module={KithWeb.ContactLive.PetsComponent}
+ id={"mobile-pets-#{@contact.id}"}
+ contact_id={@contact.id}
+ account_id={@account_id}
+ can_edit={can?(assigns, :create, :pet)}
+ />
+ <.live_component
+ module={KithWeb.ContactLive.DebtsComponent}
+ id={"mobile-debts-#{@contact.id}"}
+ contact_id={@contact.id}
+ account_id={@account_id}
+ current_user_id={@current_user_id}
+ can_edit={can?(assigns, :create, :debt)}
+ />
+
+
+
+
- <%!-- Sidebar sub-sections --%>
-
- <.live_component
- module={KithWeb.ContactLive.AddressesComponent}
- id={"addresses-#{@contact.id}"}
- contact_id={@contact.id}
- account_id={@account_id}
- can_edit={can?(assigns, :create, :address)}
- />
-
+ <%!-- ========== TWO COLUMN LAYOUT ========== --%>
+
+ <%!-- ===== LEFT SIDEBAR (hidden on mobile — shown as tabs above) ===== --%>
+
+ <%!-- CORE: Basic Info --%>
+
+
+ Basic Info
+
+
+
+
Birthday
+
+
+
+ ({age})
+
+
+
+
+
Gender
+ {@contact.gender.name}
+
+
+
Occupation
+ {@contact.occupation}
+
+
+
Company
+ {@contact.company}
+
+
+
Last contact
+
+
+
+
+
+
Deceased
+
+
+
+
-
+ <%!-- CORE: Contact Details (fields + addresses merged) --%>
+
<.live_component
module={KithWeb.ContactLive.ContactFieldsComponent}
id={"contact-fields-#{@contact.id}"}
@@ -229,9 +417,19 @@
phone_format={@current_scope.account.phone_format || "e164"}
can_edit={can?(assigns, :create, :contact_field)}
/>
-
+
+ <.live_component
+ module={KithWeb.ContactLive.AddressesComponent}
+ id={"addresses-#{@contact.id}"}
+ contact_id={@contact.id}
+ account_id={@account_id}
+ can_edit={can?(assigns, :create, :address)}
+ />
+
+
-
+ <%!-- CORE: Relationships --%>
+
<.live_component
module={KithWeb.ContactLive.RelationshipsComponent}
id={"relationships-#{@contact.id}"}
@@ -239,187 +437,137 @@
account_id={@account_id}
can_edit={can?(assigns, :create, :relationship)}
/>
-
-
-
- <.live_component
- module={KithWeb.ContactLive.RemindersComponent}
- id={"reminders-#{@contact.id}"}
- contact_id={@contact.id}
- account_id={@account_id}
- />
-
-
-
- <.live_component
- module={KithWeb.ContactLive.PetsComponent}
- id={"pets-#{@contact.id}"}
- contact_id={@contact.id}
- account_id={@account_id}
- can_edit={can?(assigns, :create, :pet)}
- />
-
+
-
+ <%!-- CORE: About & How We Met --%>
+
<.live_component
- module={KithWeb.ContactLive.DebtsComponent}
- id={"debts-#{@contact.id}"}
- contact_id={@contact.id}
+ module={KithWeb.ContactLive.AboutComponent}
+ id={"about-#{@contact.id}"}
+ contact={@contact}
account_id={@account_id}
- current_user_id={@current_user_id}
- can_edit={can?(assigns, :create, :debt)}
+ can_edit={can?(assigns, :update, :contact)}
/>
-
+
- <%!-- Action buttons --%>
- <%= if can?(assigns, :update, :contact) do %>
-
-
-
+
+
+ Tags
+ <%= if can?(assigns, :update, :tag) do %>
+
- <.icon name="hero-pencil-square" class="size-4" /> Edit
-
-
- <%= if @contact.is_archived do %>
-
- <.icon name="hero-archive-box-arrow-down" class="size-4" /> Unarchive
-
- <% else %>
-
- <.icon name="hero-archive-box" class="size-4" /> Archive
-
+ + Add
+
+ <% end %>
+
+
+
+
+ <%= if can?(assigns, :update, :tag) do %>
+
+ ×
+
<% end %>
-
-
- <.icon name="hero-trash" class="size-4" /> Delete
-
-
-
- <.icon name="hero-arrows-right-left" class="size-4" /> Merge
-
-
-
- <% end %>
-
-
- <%!-- Main content area with tabs --%>
-
-
-
-
+
- {tab_label(tab)}
-
-
+ No tags
+
+
+ <%!-- Tag dropdown --%>
+ <%= if @show_tag_dropdown do %>
+
+ <% end %>
-
- <%= case @active_tab do %>
- <% :notes -> %>
- <.live_component
- module={KithWeb.ContactLive.NotesListComponent}
- id={"notes-#{@contact.id}"}
- contact_id={@contact.id}
- account_id={@account_id}
- current_user_id={@current_user_id}
- can_edit={can?(assigns, :create, :note)}
- />
- <% :life_events -> %>
- <.live_component
- module={KithWeb.ContactLive.LifeEventsListComponent}
- id={"life-events-#{@contact.id}"}
- contact_id={@contact.id}
- account_id={@account_id}
- can_edit={can?(assigns, :create, :life_event)}
- />
- <% :activities -> %>
- <.live_component
- module={KithWeb.ContactLive.ActivitiesListComponent}
- id={"activities-#{@contact.id}"}
- contact_id={@contact.id}
- account_id={@account_id}
- can_edit={can?(assigns, :create, :activity)}
- />
- <% :calls -> %>
- <.live_component
- module={KithWeb.ContactLive.CallsListComponent}
- id={"calls-#{@contact.id}"}
- contact_id={@contact.id}
- account_id={@account_id}
- can_edit={can?(assigns, :create, :call)}
- />
- <% :tasks -> %>
- <.live_component
- module={KithWeb.ContactLive.TasksComponent}
- id={"tasks-#{@contact.id}"}
- contact_id={@contact.id}
- account_id={@account_id}
- current_user_id={@current_user_id}
- can_edit={can?(assigns, :create, :task)}
- />
- <% :gifts -> %>
- <.live_component
- module={KithWeb.ContactLive.GiftsComponent}
- id={"gifts-#{@contact.id}"}
- contact_id={@contact.id}
- account_id={@account_id}
- current_user_id={@current_user_id}
- can_edit={can?(assigns, :create, :gift)}
+ <%!-- MORE DRAWER --%>
+
+
+ More
+
+ Reminders, Pets, Debts
+ <.icon
+ name="hero-chevron-down-mini"
+ class={[
+ "size-3 transition-transform duration-200",
+ @show_more_drawer && "rotate-180"
+ ]}
/>
- <% :conversations -> %>
- <.live_component
- module={KithWeb.ContactLive.ConversationsComponent}
- id={"conversations-#{@contact.id}"}
- contact_id={@contact.id}
- account_id={@account_id}
- current_user_id={@current_user_id}
- can_edit={can?(assigns, :create, :conversation)}
- />
- <% :photos -> %>
- <.live_component
- module={KithWeb.ContactLive.PhotosGalleryComponent}
- id={"photos-#{@contact.id}"}
- contact={@contact}
- account_id={@account_id}
- can_edit={can?(assigns, :create, :photo)}
- />
- <% end %>
+
+
+
+ <.live_component
+ module={KithWeb.ContactLive.RemindersComponent}
+ id={"reminders-#{@contact.id}"}
+ contact_id={@contact.id}
+ account_id={@account_id}
+ creator_id={@current_user_id}
+ can_edit={can?(assigns, :create, :reminder)}
+ />
+ <.live_component
+ module={KithWeb.ContactLive.PetsComponent}
+ id={"pets-#{@contact.id}"}
+ contact_id={@contact.id}
+ account_id={@account_id}
+ can_edit={can?(assigns, :create, :pet)}
+ />
+ <.live_component
+ module={KithWeb.ContactLive.DebtsComponent}
+ id={"debts-#{@contact.id}"}
+ contact_id={@contact.id}
+ account_id={@account_id}
+ current_user_id={@current_user_id}
+ can_edit={can?(assigns, :create, :debt)}
+ />
+
+
+
+ <%!-- ===== RIGHT: ACTIVITY STREAM ===== --%>
+
+ <.live_component
+ module={KithWeb.ContactLive.ActivityStreamComponent}
+ id={"activity-stream-#{@contact.id}"}
+ contact_id={@contact.id}
+ account_id={@account_id}
+ current_user_id={@current_user_id}
+ can_edit={can?(assigns, :create, :note)}
+ />
<% end %>
diff --git a/test/kith/contacts/activity_stream_test.exs b/test/kith/contacts/activity_stream_test.exs
new file mode 100644
index 0000000..144dc6a
--- /dev/null
+++ b/test/kith/contacts/activity_stream_test.exs
@@ -0,0 +1,218 @@
+defmodule Kith.Contacts.ActivityStreamTest do
+ use Kith.DataCase, async: true
+
+ alias Kith.Contacts.ActivityStream
+
+ import Kith.AccountsFixtures
+ import Kith.ContactsFixtures
+
+ setup do
+ seed_reference_data!()
+ user = user_fixture()
+ scope = user_scope_fixture(user)
+ contact = contact_fixture(scope.account.id)
+
+ %{user: user, scope: scope, contact: contact, account_id: scope.account.id}
+ end
+
+ describe "list_activity/3" do
+ test "returns empty list when contact has no activity", ctx do
+ entries =
+ ActivityStream.list_activity(ctx.account_id, ctx.contact.id, current_user_id: ctx.user.id)
+
+ assert entries == []
+ end
+
+ test "returns notes in the stream", ctx do
+ note_fixture(ctx.contact, ctx.user.id, %{"body" => "Hello world
"})
+
+ entries =
+ ActivityStream.list_activity(ctx.account_id, ctx.contact.id, current_user_id: ctx.user.id)
+
+ assert length(entries) == 1
+ assert [%{type: :note, body: "Hello world
"}] = entries
+ end
+
+ test "returns calls in the stream", ctx do
+ {:ok, _call} =
+ Kith.Activities.create_call(ctx.contact, %{
+ "occurred_at" => DateTime.utc_now(),
+ "duration_mins" => 15,
+ "notes" => "Talked about plans"
+ })
+
+ entries =
+ ActivityStream.list_activity(ctx.account_id, ctx.contact.id, current_user_id: ctx.user.id)
+
+ assert length(entries) == 1
+ assert [%{type: :call}] = entries
+ end
+
+ test "returns life events in the stream", ctx do
+ [type | _] = Kith.Repo.all(Kith.Contacts.LifeEventType)
+
+ {:ok, _event} =
+ Kith.Activities.create_life_event(ctx.contact, %{
+ "occurred_on" => ~D[2025-06-15],
+ "life_event_type_id" => type.id,
+ "note" => "Big day"
+ })
+
+ entries =
+ ActivityStream.list_activity(ctx.account_id, ctx.contact.id, current_user_id: ctx.user.id)
+
+ assert length(entries) == 1
+ assert [%{type: :life_event, body: "Big day"}] = entries
+ end
+
+ test "returns tasks in the stream", ctx do
+ {:ok, _task} =
+ Kith.Tasks.create_task(ctx.account_id, ctx.user.id, %{
+ "title" => "Buy birthday gift",
+ "contact_id" => ctx.contact.id
+ })
+
+ entries =
+ ActivityStream.list_activity(ctx.account_id, ctx.contact.id, current_user_id: ctx.user.id)
+
+ assert length(entries) == 1
+ assert [%{type: :task, title: "Buy birthday gift"}] = entries
+ end
+
+ test "returns gifts in the stream", ctx do
+ {:ok, _gift} =
+ Kith.Gifts.create_gift(ctx.account_id, ctx.user.id, %{
+ "name" => "A book",
+ "direction" => "given",
+ "contact_id" => ctx.contact.id
+ })
+
+ entries =
+ ActivityStream.list_activity(ctx.account_id, ctx.contact.id, current_user_id: ctx.user.id)
+
+ assert length(entries) == 1
+ assert [%{type: :gift, title: "A book"}] = entries
+ end
+
+ test "returns conversations in the stream", ctx do
+ {:ok, _conv} =
+ Kith.Conversations.create_conversation(ctx.account_id, ctx.user.id, %{
+ "subject" => "Weekend plans",
+ "platform" => "whatsapp",
+ "contact_id" => ctx.contact.id
+ })
+
+ entries =
+ ActivityStream.list_activity(ctx.account_id, ctx.contact.id, current_user_id: ctx.user.id)
+
+ assert length(entries) == 1
+ assert [%{type: :conversation, title: "Weekend plans"}] = entries
+ end
+
+ test "returns photos in the stream", ctx do
+ photo_fixture(ctx.contact)
+
+ entries =
+ ActivityStream.list_activity(ctx.account_id, ctx.contact.id, current_user_id: ctx.user.id)
+
+ assert length(entries) == 1
+ assert [%{type: :photo}] = entries
+ end
+
+ test "merges multiple types sorted by date descending", ctx do
+ # Create entries with staggered timestamps
+ note_fixture(ctx.contact, ctx.user.id, %{"body" => "Old note
"})
+ Process.sleep(10)
+
+ {:ok, _call} =
+ Kith.Activities.create_call(ctx.contact, %{
+ "occurred_at" => DateTime.utc_now(),
+ "notes" => "Recent call"
+ })
+
+ Process.sleep(10)
+ note_fixture(ctx.contact, ctx.user.id, %{"body" => "Newest note
"})
+
+ entries =
+ ActivityStream.list_activity(ctx.account_id, ctx.contact.id, current_user_id: ctx.user.id)
+
+ assert length(entries) == 3
+ types = Enum.map(entries, & &1.type)
+ # Newest first
+ assert List.first(types) == :note
+ end
+
+ test "filters by type", ctx do
+ note_fixture(ctx.contact, ctx.user.id)
+ photo_fixture(ctx.contact)
+
+ {:ok, _call} =
+ Kith.Activities.create_call(ctx.contact, %{
+ "occurred_at" => DateTime.utc_now()
+ })
+
+ notes_only =
+ ActivityStream.list_activity(ctx.account_id, ctx.contact.id,
+ current_user_id: ctx.user.id,
+ types: [:note]
+ )
+
+ assert length(notes_only) == 1
+ assert [%{type: :note}] = notes_only
+
+ notes_and_photos =
+ ActivityStream.list_activity(ctx.account_id, ctx.contact.id,
+ current_user_id: ctx.user.id,
+ types: [:note, :photo]
+ )
+
+ assert length(notes_and_photos) == 2
+ types = Enum.map(notes_and_photos, & &1.type) |> MapSet.new()
+ assert MapSet.equal?(types, MapSet.new([:note, :photo]))
+ end
+
+ test "respects limit option", ctx do
+ for i <- 1..5 do
+ note_fixture(ctx.contact, ctx.user.id, %{"body" => "Note #{i}
"})
+ end
+
+ entries =
+ ActivityStream.list_activity(ctx.account_id, ctx.contact.id,
+ current_user_id: ctx.user.id,
+ limit: 3
+ )
+
+ assert length(entries) == 3
+ end
+
+ test "each entry has required fields", ctx do
+ note_fixture(ctx.contact, ctx.user.id, %{"body" => "Test
"})
+
+ [entry] =
+ ActivityStream.list_activity(ctx.account_id, ctx.contact.id, current_user_id: ctx.user.id)
+
+ assert Map.has_key?(entry, :id)
+ assert Map.has_key?(entry, :type)
+ assert Map.has_key?(entry, :title)
+ assert Map.has_key?(entry, :body)
+ assert Map.has_key?(entry, :occurred_at)
+ assert Map.has_key?(entry, :record)
+ assert %DateTime{} = entry.occurred_at
+ end
+ end
+
+ describe "all_types/0" do
+ test "returns all 8 types" do
+ types = ActivityStream.all_types()
+ assert length(types) == 8
+ assert :note in types
+ assert :call in types
+ assert :life_event in types
+ assert :activity in types
+ assert :task in types
+ assert :gift in types
+ assert :conversation in types
+ assert :photo in types
+ end
+ end
+end
diff --git a/test/kith/contacts/phone_formatter_test.exs b/test/kith/contacts/phone_formatter_test.exs
index 705865c..5dfd878 100644
--- a/test/kith/contacts/phone_formatter_test.exs
+++ b/test/kith/contacts/phone_formatter_test.exs
@@ -16,12 +16,16 @@ defmodule Kith.Contacts.PhoneFormatterTest do
assert {:ok, "+12345678901"} = PhoneFormatter.normalize("+12345678901")
end
- test "adds country code to 10-digit US number" do
- assert {:ok, "+12345678901"} = PhoneFormatter.normalize("2345678901")
+ test "preserves bare 10-digit number without adding country code" do
+ assert {:ok, "2345678901"} = PhoneFormatter.normalize("2345678901")
end
- test "strips formatting and normalizes" do
- assert {:ok, "+12345678901"} = PhoneFormatter.normalize("(234) 567-8901")
+ test "strips formatting from 10-digit number" do
+ assert {:ok, "2345678901"} = PhoneFormatter.normalize("(234) 567-8901")
+ end
+
+ test "does not assume country code for 10-digit numbers" do
+ assert {:ok, "9876543210"} = PhoneFormatter.normalize("987-654-3210")
end
test "handles 11-digit number starting with 1" do
diff --git a/test/kith_web/live/contact_live/edit_test.exs b/test/kith_web/live/contact_live/edit_test.exs
new file mode 100644
index 0000000..890195f
--- /dev/null
+++ b/test/kith_web/live/contact_live/edit_test.exs
@@ -0,0 +1,50 @@
+defmodule KithWeb.ContactLive.EditTest do
+ use KithWeb.ConnCase, async: true
+
+ import Phoenix.LiveViewTest
+ import Kith.ContactsFixtures
+
+ setup :register_and_log_in_user
+
+ setup %{user: user} do
+ seed_reference_data!()
+ contact = contact_fixture(user.account_id)
+ %{contact: contact, account_id: user.account_id}
+ end
+
+ describe "edit page" do
+ test "renders edit form", %{conn: conn, contact: contact} do
+ {:ok, _view, html} = live(conn, ~p"/contacts/#{contact.id}/edit")
+ assert html =~ "Edit"
+ assert html =~ contact.first_name
+ end
+
+ test "saves contact successfully without socket crash", %{conn: conn, contact: contact} do
+ {:ok, view, _html} = live(conn, ~p"/contacts/#{contact.id}/edit")
+
+ view
+ |> form("#contact-form", %{contact: %{first_name: "Updated"}})
+ |> render_submit()
+
+ # Should redirect to show page (not crash the socket)
+ flash = assert_redirect(view, ~p"/contacts/#{contact.id}")
+ assert flash["info"] =~ "updated"
+
+ # Verify DB was updated
+ updated = Kith.Contacts.get_contact!(contact.account_id, contact.id)
+ assert updated.first_name == "Updated"
+ end
+
+ test "shows validation errors for invalid data", %{conn: conn, contact: contact} do
+ {:ok, view, _html} = live(conn, ~p"/contacts/#{contact.id}/edit")
+
+ html =
+ view
+ |> form("#contact-form", %{contact: %{first_name: ""}})
+ |> render_submit()
+
+ # Should stay on the page with errors, not crash
+ assert html =~ "contact-form"
+ end
+ end
+end
diff --git a/test/kith_web/live/contact_live/show_test.exs b/test/kith_web/live/contact_live/show_test.exs
index 7cf52a5..9d92d80 100644
--- a/test/kith_web/live/contact_live/show_test.exs
+++ b/test/kith_web/live/contact_live/show_test.exs
@@ -3,6 +3,7 @@ defmodule KithWeb.ContactLive.ShowTest do
import Phoenix.LiveViewTest
import Kith.ContactsFixtures
+ import Kith.RemindersFixtures
setup :register_and_log_in_user
@@ -13,36 +14,31 @@ defmodule KithWeb.ContactLive.ShowTest do
end
describe "contact profile page" do
- test "renders contact name and sidebar", %{conn: conn, contact: contact} do
+ test "renders hero banner with contact name", %{conn: conn, contact: contact} do
{:ok, _view, html} = live(conn, ~p"/contacts/#{contact.id}")
assert html =~ contact.display_name
- assert html =~ "Notes"
end
- test "tab switching works", %{conn: conn, contact: contact} do
- {:ok, view, _html} = live(conn, ~p"/contacts/#{contact.id}")
-
- # The contact show page has 3 tabs: Notes (default), Life Events, Photos
- # Addresses, Contact Info, and Relationships are sidebar cards (always visible)
- html = view |> element("button", "Life Events") |> render_click()
- assert html =~ "Life Events"
-
- html = view |> element("button", "Photos") |> render_click()
- assert html =~ "Photos"
-
- html = view |> element("button", "Notes") |> render_click()
- assert html =~ "Notes"
+ test "renders activity stream", %{conn: conn, contact: contact} do
+ {:ok, _view, html} = live(conn, ~p"/contacts/#{contact.id}")
+ assert html =~ "Activity"
+ assert html =~ "Filter"
end
- test "shows empty state messages", %{conn: conn, contact: contact} do
+ test "shows empty state when no activity", %{conn: conn, contact: contact} do
{:ok, _view, html} = live(conn, ~p"/contacts/#{contact.id}")
- assert html =~ "No notes yet"
+ assert html =~ "No activity yet"
end
- test "reminders sidebar renders", %{conn: conn, contact: contact} do
- {:ok, _view, html} = live(conn, ~p"/contacts/#{contact.id}")
+ test "more drawer toggles", %{conn: conn, contact: contact} do
+ {:ok, view, html} = live(conn, ~p"/contacts/#{contact.id}")
+ # More drawer is collapsed by default
+ assert html =~ "Reminders, Pets, Debts"
+ refute html =~ "No reminders set."
+
+ # Click to expand
+ html = view |> element("button[phx-click=toggle-more-drawer]") |> render_click()
assert html =~ "Reminders"
- assert html =~ "No reminders set."
end
test "favorite toggle works", %{conn: conn, contact: contact} do
@@ -54,37 +50,34 @@ defmodule KithWeb.ContactLive.ShowTest do
end
end
- describe "notes tab" do
- test "create a note via context and verify it renders", %{
- conn: conn,
- contact: contact,
- user: user
- } do
- # Create note directly via context (Trix hidden input can't be set via LiveViewTest)
+ describe "activity stream" do
+ test "notes appear in unified stream", %{conn: conn, contact: contact, user: user} do
note_fixture(contact, user.id, %{"body" => "Test note content
"})
{:ok, _view, html} = live(conn, ~p"/contacts/#{contact.id}")
assert html =~ "Test note content"
+ assert html =~ "Note"
end
- test "shows Add Note button", %{conn: conn, contact: contact} do
- {:ok, view, _html} = live(conn, ~p"/contacts/#{contact.id}")
-
- # Click the header "Add Note" button
- html = view |> element("#add-note-#{contact.id}") |> render_click()
- assert html =~ "trix-editor"
- assert html =~ "Private"
+ test "quick-add buttons are visible", %{conn: conn, contact: contact} do
+ {:ok, _view, html} = live(conn, ~p"/contacts/#{contact.id}")
+ assert html =~ "+ Note"
+ assert html =~ "+ Call"
+ assert html =~ "+ Event"
end
end
- describe "addresses section" do
- test "add an address", %{conn: conn, contact: contact} do
+ describe "sidebar sections" do
+ test "basic info card renders", %{conn: conn, contact: contact} do
+ {:ok, _view, html} = live(conn, ~p"/contacts/#{contact.id}")
+ assert html =~ "Basic Info"
+ end
+
+ test "addresses section works", %{conn: conn, contact: contact} do
{:ok, view, _html} = live(conn, ~p"/contacts/#{contact.id}")
- # Addresses section is always visible in the sidebar — target header button
view |> element("#add-address-#{contact.id}") |> render_click()
- # Submit via render_submit with the component target
view
|> form("form[phx-submit=save]", %{
address: %{
@@ -97,39 +90,128 @@ defmodule KithWeb.ContactLive.ShowTest do
})
|> render_submit()
- # Verify the address was created in the database
addresses = Kith.Contacts.list_addresses(contact.id)
assert length(addresses) == 1
assert hd(addresses).line1 == "456 Oak Ave"
- # Re-render to see the address displayed
html = render(view)
assert html =~ "456 Oak Ave"
end
+
+ test "contact fields section works", %{conn: conn, contact: contact, account_id: account_id} do
+ [email_type | _] = Kith.Contacts.list_contact_field_types(account_id)
+
+ {:ok, _field} =
+ Kith.Contacts.create_contact_field(
+ Kith.Contacts.get_contact!(account_id, contact.id),
+ %{"value" => "jane@example.com", "contact_field_type_id" => email_type.id}
+ )
+
+ {:ok, _view, html} = live(conn, ~p"/contacts/#{contact.id}")
+ assert html =~ "jane@example.com"
+ end
+ end
+
+ describe "birthday badge" do
+ test "shows birthday badge when contact has birthdate", %{conn: conn, account_id: account_id} do
+ birthday_contact =
+ contact_fixture(account_id, %{birthdate: Date.add(Date.utc_today(), 5)})
+
+ {:ok, _view, html} = live(conn, ~p"/contacts/#{birthday_contact.id}")
+ assert html =~ "Birthday in 5 days"
+ end
+
+ test "shows birthday today badge", %{conn: conn, account_id: account_id} do
+ today = Date.utc_today()
+ # Same month/day but a past year ensures birthday is today
+ birthdate = %{today | year: today.year - 30}
+
+ birthday_contact = contact_fixture(account_id, %{birthdate: birthdate})
+
+ {:ok, _view, html} = live(conn, ~p"/contacts/#{birthday_contact.id}")
+ assert html =~ "Birthday today"
+ end
+
+ test "no birthday badge when no birthdate set", %{conn: conn, contact: contact} do
+ {:ok, _view, html} = live(conn, ~p"/contacts/#{contact.id}")
+ refute html =~ "Birthday"
+ end
end
- describe "contact fields section" do
- test "add a contact field", %{conn: conn, contact: contact, account_id: account_id} do
+ describe "reminders section" do
+ test "shows existing reminders in more drawer", %{
+ conn: conn,
+ contact: contact,
+ account_id: account_id,
+ user: user
+ } do
+ reminder_fixture(account_id, contact.id, user.id, %{title: "Call them back"})
+
{:ok, view, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = view |> element("button[phx-click=toggle-more-drawer]") |> render_click()
+ assert html =~ "Call them back"
+ end
- # Contact Info section is always visible in the sidebar
- [email_type | _] = Kith.Contacts.list_contact_field_types(account_id)
+ test "shows Add button in reminders section", %{conn: conn, contact: contact} do
+ {:ok, view, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = view |> element("button[phx-click=toggle-more-drawer]") |> render_click()
+ assert html =~ "Add"
+ end
- view |> element("button", "Add Info") |> render_click()
+ test "can create a reminder via the context", %{
+ conn: conn,
+ contact: contact,
+ account_id: account_id,
+ user: user
+ } do
+ # Create reminder via context and verify it appears in the UI
+ next_date = Date.add(Date.utc_today(), 7)
+
+ {:ok, _reminder} =
+ Kith.Reminders.create_reminder(account_id, user.id, %{
+ "type" => "one_time",
+ "title" => "Follow up call",
+ "next_reminder_date" => next_date,
+ "contact_id" => contact.id,
+ "account_id" => account_id,
+ "creator_id" => user.id
+ })
- view
- |> form("form[phx-submit=save]", %{
- contact_field: %{value: "jane@example.com", contact_field_type_id: email_type.id}
- })
- |> render_submit()
+ {:ok, view, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = view |> element("button[phx-click=toggle-more-drawer]") |> render_click()
+ assert html =~ "Follow up call"
+
+ # Verify the Add button renders (component CRUD is functional)
+ assert html =~ "Add"
+ end
+
+ test "can delete a reminder via the context", %{
+ contact: contact,
+ account_id: account_id,
+ user: user
+ } do
+ reminder = reminder_fixture(account_id, contact.id, user.id, %{title: "Delete me"})
+
+ assert [_] = Kith.Reminders.list_reminders(account_id, contact.id)
+ {:ok, _} = Kith.Reminders.delete_reminder(reminder)
+ assert [] = Kith.Reminders.list_reminders(account_id, contact.id)
+ end
+ end
- # Verify in database
- fields = Kith.Contacts.list_contact_fields(contact.id)
- assert length(fields) == 1
- assert hd(fields).value == "jane@example.com"
+ describe "photo upload from timeline" do
+ test "photo modal shows upload form instead of redirect", %{conn: conn, contact: contact} do
+ {:ok, view, _html} = live(conn, ~p"/contacts/#{contact.id}")
+
+ # The photo button is in an Alpine dropdown. Find it by its phx attributes.
+ view
+ |> element(~s|button[phx-click="open-entry-modal"][phx-value-type="photo"]|)
+ |> render_click()
html = render(view)
- assert html =~ "jane@example.com"
+ # Should show upload form, not a redirect link
+ assert html =~ "Upload Photos"
+ assert html =~ "photo-upload-form"
+ refute html =~ "/edit#photos"
end
end
end