Skip to content

Accountability partners

Overview

Accountability partners let two allies commit to holding each other accountable on their goals. A user sends a partner request to an existing accepted ally; the ally accepts or declines. Once active, both users see each other's check-in status, build a mutual streak, and earn bonus XP. Either side can end the partnership at any time.

The feature has two layers:

  1. Partnership lifecycle — backend interactions and GraphQL mutations for requesting, accepting, declining, and ending a partnership.
  2. Dashboard widget — a compact card showing partner status, mutual streak, and a daily nudge button.

Constraints

  • One active accountability partner per user (MVP).
  • Users must be accepted allies before requesting a partnership.
  • Ending a partnership zeroes the mutual streak but leaves individual streaks and XP untouched.

Partnership lifecycle

Four interactions in rails_api/app/interactions/social/ manage the partnership state on the UserAlly model. All require authentication and operate on the accountability_partner boolean and related columns on user_allies.

Social::SendPartnerRequest

File: rails_api/app/interactions/social/send_partner_request.rb

Sends a partner request to an accepted ally.

InputTypeDescription
ally_public_idStringPublic ID of the target ally
current_userUserAuthenticated user

Guards: require_current_user, require_ally_exists, require_no_active_partnership, require_no_pending_request

Behavior:

  • Sets accountability_partner_requested: true and accountability_partner_requested_by_id on the UserAlly record.
  • Generates a partner_request notification for the target ally.
  • Cross-request auto-accept: if the ally already sent a request to the current user, delegates to AcceptPartnerRequest and returns early instead of creating a duplicate request.

Error codes: :unauthorized, :not_found, :conflict

Social::AcceptPartnerRequest

File: rails_api/app/interactions/social/accept_partner_request.rb

Accepts a pending partner request.

InputTypeDescription
ally_public_idStringPublic ID of the requesting ally
current_userUserAuthenticated user

Guards: require_current_user, require_pending_request_exists, require_no_active_partnership

Behavior:

  • Sets accountability_partner: true, accountability_partner_since, clears request flags, initializes mutual_streak_count: 0 on the UserAlly record.
  • Generates a partner_accepted notification for the requester.
  • Awards +50 XP to each user via GamificationService.award_xp.
  • Triggers UserAction.first_accountability_partner for both users if this is their first partnership.

Social::DeclinePartnerRequest

File: rails_api/app/interactions/social/decline_partner_request.rb

Declines a pending partner request.

InputTypeDescription
ally_public_idStringPublic ID of the requesting ally
current_userUserAuthenticated user

Guards: require_current_user, require_pending_request_exists

Behavior:

  • Clears accountability_partner_requested and accountability_partner_requested_by_id. Ally relationship is unchanged.
  • Generates a partner_declined notification for the requester (notify_requester, Phase 2/OBJ-2880) — captures the requester's id before the update clears it, mirroring AcceptPartnerRequest. Symmetric with accept and end; lands in the tray silently (no toast, push: false) rather than interrupting.

Social::EndPartnership

File: rails_api/app/interactions/social/end_partnership.rb

Ends an active partnership.

InputTypeDescription
ally_public_idStringPublic ID of the partner ally
current_userUserAuthenticated user

Guards: require_current_user, require_active_partnership

Behavior:

  • Sets accountability_partner: false, zeroes mutual_streak_count, sets partner_ended_at.
  • Generates a partner_ended notification (neutral tone) for the other user.
  • Individual streaks, XP, and badges are not affected.

GraphQL API

Mutations

All four mutations live in rails_api/app/graphql/mutations/ and are registered in mutation_type.rb. Each requires authentication (require_auth!), takes allyPublicId: ID!, and returns { userAlly: UserAllyType, errors: [String] }.

sendPartnerRequest

graphql
mutation SendPartnerRequest($allyPublicId: ID!) {
  sendPartnerRequest(allyPublicId: $allyPublicId) {
    userAlly {
      publicId
      partnerStatus
      accountabilityPartner
      accountabilityPartnerSince
      mutualStreakCount
    }
    errors
  }
}

acceptPartnerRequest

graphql
mutation AcceptPartnerRequest($allyPublicId: ID!) {
  acceptPartnerRequest(allyPublicId: $allyPublicId) {
    userAlly {
      publicId
      partnerStatus
      accountabilityPartner
      accountabilityPartnerSince
      mutualStreakCount
    }
    errors
  }
}

declinePartnerRequest

graphql
mutation DeclinePartnerRequest($allyPublicId: ID!) {
  declinePartnerRequest(allyPublicId: $allyPublicId) {
    userAlly {
      publicId
      partnerStatus
      accountabilityPartner
    }
    errors
  }
}

endPartnership

graphql
mutation EndPartnership($allyPublicId: ID!) {
  endPartnership(allyPublicId: $allyPublicId) {
    userAlly {
      publicId
      partnerStatus
      accountabilityPartner
    }
    errors
  }
}

sendPartnerNudge

Sends a daily supportive nudge to an accountability partner.

graphql
mutation SendPartnerNudge($allyPublicId: ID!) {
  sendPartnerNudge(allyPublicId: $allyPublicId) {
    success
    errors
  }
}

Constraints:

  • One nudge per partnership per direction per day — DB-enforced, not just guard-checked. A→B and B→A are tracked independently: both can send a nudge on the same day, but neither can send a second one until the next day. This is enforced by a composite unique index on (user_ally_id, sender_id, sent_on) on the partner_nudges ledger table (see Data model below), not just the require_no_nudge_today guard, so it holds even under a race between two concurrent requests from the same sender.
  • Second call same day, same direction, returns success: false with error code :conflict — whether caught by the guard or by the unique-index rescue.

Query: accountabilityPartner

Returns the current user's active partner, or null if none.

graphql
query GetAccountabilityPartner($userId: ID!) {
  accountabilityPartner(userId: $userId) {
    publicId
    firstName
    lastName
    username
    photo { imageUrl }
    accountabilityPartnerSince
    mutualStreakCount
    longestMutualStreak
  }
}

Resolver: rails_api/app/graphql/resolvers/user_queries.rb — requires auth, enforces ownership (own user or admin). Looks up UserAlly.with_active_partnership for the given user.

Types

AccountabilityPartnerType (rails_api/app/graphql/types/accountability_partner_type.rb)

FieldTypeDescription
publicIdID!Partner user's public identifier
firstNameStringPartner's given name
lastNameStringPartner's family name
usernameStringPartner's unique handle
photoUserPhotoTypePartner's profile photo
accountabilityPartnerSinceISO8601DateTimeWhen the partnership was established
mutualStreakCountInteger!Current mutual check-in streak
longestMutualStreakInteger!All-time longest mutual streak
mutualStreakLastDateISO8601DateDate of the most recent mutual bonus award
partnerCheckedInTodayBoolean!Whether the partner has checked in today
userCheckedInTodayBoolean!Whether the current user has checked in today
nudgeSentTodayBoolean!Whether the current user has already sent a nudge to their partner today

UserAllyType extensions (rails_api/app/graphql/types/user_ally_type.rb)

Four fields added to the existing type:

FieldTypeDescription
accountabilityPartnerBoolean!Whether this ally is the active partner
accountabilityPartnerSinceISO8601DateTimePartnership start date
mutualStreakCountInteger!Current mutual streak count
partnerStatusString!"none", "pending", or "active"

Notifications

Six notification kinds (defined in UserNotification::KIND_ENUMS):

KindEnum valueSent whenSent to
partner_request10Request sentTarget ally
partner_accepted11Request acceptedOriginal requester
partner_ended12Partnership endedOther partner
partner_missed_day13Habit not checked in by day's endPartner (not the person who missed)
partner_nudge18User taps "Give [Name] a boost" buttonPartner
partner_declined37Request declinedOriginal requester

All four partnership-lifecycle interactions (SendPartnerRequest, AcceptPartnerRequest, DeclinePartnerRequest, EndPartnership) notify symmetrically as of Phase 2 (OBJ-2880) — partner_declined lands in the tray without a toast (see Notifications § Frontend: useNotifications Composable), the only kind of the four with that treatment.

partner_nudge content template:

%s is rooting for you — quick check-in to keep your streak alive.

Data model

Partnership state lives on the user_allies table — there is no separate join table.

ColumnTypeDefaultDescription
accountability_partnerbooleanfalseWhether this ally pair has an active partnership
accountability_partner_sincedatetimenilWhen the partnership was established
accountability_partner_requestedbooleanfalseWhether a partner request is pending
accountability_partner_requested_by_idbigintnilUser ID of the requester
mutual_streak_countinteger0Current mutual check-in streak
longest_mutual_streakinteger0All-time longest mutual streak
partner_ended_atdatetimenilWhen the last partnership ended

Migration: rails_api/db/migrate/20260422000001_add_accountability_partner_to_user_allies.rb

Model scopes (on UserAlly):

  • with_active_partnershipaccepted.where(accountability_partner: true)
  • with_pending_partner_requestaccepted.where(accountability_partner_requested: true)
  • find_for_users(user_a, user_b) — finds the accepted UserAlly record in either direction

Legacy columns (last_nudge_sent_at, last_nudge_sent_by_id): still present on user_allies but no longer the authority for the daily-nudge rule — they recorded one shared slot per pair, which couldn't represent "per direction" and let alternating nudges (A→B, B→A, A→B) bypass the daily limit. Social::SendPartnerNudge still updates them on every send (unused for the guard now), but the enforced check reads the partner_nudges ledger table instead. See Data models § Partner Nudge for the ledger's schema.


Frontend GraphQL constants

File: ionic_frontend/src/constants/graphql/social.js

ConstantType
SEND_PARTNER_REQUEST_MUTATIONMutation
ACCEPT_PARTNER_REQUEST_MUTATIONMutation
DECLINE_PARTNER_REQUEST_MUTATIONMutation
END_PARTNERSHIP_MUTATIONMutation
SEND_PARTNER_NUDGE_MUTATIONMutation
PARTNER_STATUS_QUERYQuery
GET_ACCOUNTABILITY_PARTNER_QUERYQuery

Fragment: AccountabilityPartnerFragment in ionic_frontend/src/constants/graphql/fragments.js


Frontend invite and accept flow

The invite/accept flow lets a user send a partnership request from the Goal Detail page and lets an ally accept or decline from a notification-triggered modal. Three components and one composable implement the feature.

Composable: usePartnership

Path: ionic_frontend/src/composables/usePartnership.ts

Wraps the three partnership mutations and manages module-level state for the accept modal (so NotificationsPanel can open it from anywhere).

ExportTypeDescription
sendPartnerRequest(allyPublicId)(string) => Promise<boolean>Calls SEND_PARTNER_REQUEST_MUTATION, shows success/error toast
acceptPartnerRequest(allyPublicId)(string) => Promise<boolean>Calls ACCEPT_PARTNER_REQUEST_MUTATION, clears pending state on success
declinePartnerRequest(allyPublicId)(string) => Promise<boolean>Calls DECLINE_PARTNER_REQUEST_MUTATION, clears pending state on success
openAcceptModal(allyPublicId, name)(string, string) => voidSets module-level refs to open AcceptPartnershipModal
clearPendingRequest()() => voidResets modal state
pendingRequestPublicIdRef<string | null>Drives AcceptPartnershipModal visibility
pendingRequestNameRef<string>Requester name displayed in the accept modal
endPartnership(allyPublicId)(string) => Promise<boolean>Calls END_PARTNERSHIP_MUTATION, shows success/error toast (OBJ-3009)
sendLoading / acceptLoading / declineLoading / endLoadingRef<boolean>Mutation loading flags

After a successful send, the composable refetches USER_ALLIES_WITH_PARTNER_STATUS_QUERY so the ally list and Goal.vue pending badge update. After a successful accept, it refetches PARTNER_STATUS_QUERY so the dashboard widget appears. After a successful end, it refetches both queries — see My Allies: partner affordance, cap legibility, and ending a partnership below for why.

Each action has its own manual in-flight ref (sending / accepting / declining / ending) rather than reading Apollo's own loading, which can't stop a same-tick re-entrant call (OBJ-2673). sendPartnerRequest's success toast reads Partner request sent. — shared with FindPartnerModal, so both entry points say the same thing (OBJ-3009).

Component: FindPartnerModal.vue

Path: ionic_frontend/src/components/partners/FindPartnerModal.vue

An Ionic modal listing the user's accepted allies who are not already partnered (partnerStatus === 'none'). Opened from the Goal Detail page via the "Find a Partner" button.

Props: isOpen: boolean, goalName?: stringEmits: closed, sent(allyPublicId)

UX flow:

  1. Modal opens, fetches USER_ALLIES_WITH_PARTNER_STATUS_QUERY for the current user.
  2. Allies with partnerStatus !== 'none' are filtered out.
  3. User taps an ally row — inline confirmation panel expands: "Invite [Name] as your accountability partner for [Goal]?"
  4. User taps "Send Invite" — loading spinner, then success toast ("Partner request sent.") and auto-close.
  5. Empty state shown when no available allies exist.

Component: AcceptPartnershipModal.vue

Path: ionic_frontend/src/components/partners/AcceptPartnershipModal.vue

Globally mounted in App.vue. Visibility is driven by pendingRequestPublicId from the usePartnership composable — any code that calls openAcceptModal() triggers it.

UX flow:

  1. Displays: "[Name] wants to be your accountability partner. Hold each other to your goals and build a streak together."
  2. Two actions: "Let's do it!" (accept) and "Decline".
  3. Both show loading spinners during mutation, auto-close on success.

Goal.vue integration

Path: ionic_frontend/src/views/Goal.vue

Three conditional states render in the partner section of the Goal Detail page:

StateWhat renders
Active partner existsBadge: "Partnered with [Name]" with avatar
Pending request sentBadge: "Request pending…" (amber)
No partner, no pendingButton: "Find a Partner" — opens FindPartnerModal

activePartner comes from usePartnerStatus(). hasPendingRequest queries USER_ALLIES_WITH_PARTNER_STATUS_QUERY and checks for any ally with partnerStatus === 'pending'.

Notification wiring

Path: ionic_frontend/src/components/ui/NotificationsPanel.vue

When the user taps a partner_request notification, the panel extracts the requester's public_id from the notification details JSON and the full name from the notification content string (regex: /^(.+?) wants to be/). It then calls openAcceptModal(requesterPublicId, name) to surface AcceptPartnershipModal.

My Allies: partner affordance, cap legibility, and ending a partnership

Path: ionic_frontend/src/views/AlliesPage.vue

Before OBJ-3009, the only entry point into the partner flow was Goal.vue's "Find a Partner" button (buried on a single goal's detail page), and endPartnership had zero UI call sites — a user with a partner had no way to swap or remove one. My Allies is now the primary, non-buried entry point: each ally row carries a per-ally partner control, and the page header states the one-partner cap before the user taps anything. Design contract: docs/ui-specs/obj-3008-partner-affordance.md.

Header sub-copy (headerPartnerLine, AlliesPage.vue:489-498) — one of three variants, shown above the ally list whenever allies.length > 0:

ConditionCopy
No partner, no open requestOne ally can be your accountability partner — ask anyone here.
Active partner{FirstName} is your accountability partner. One at a time — end it to pick someone else.
Outgoing request pending, no partnerA partner request with {FirstName} is still open. One partner at a time.

Per-row control — a second line renders under the ally's name (line 1: avatar, name, AllyPartnerBadge, Remove button — unchanged) for three of the four partnerStatus states. pending gets no second line; a row with an incoming request is a dead end on this page today, because UserAlly#partner_status returns a flat 'pending' for both directions with no way to tell an outgoing request from an incoming one — the UI-SPEC deliberately ships no forward action there rather than copy that's wrong half the time (user_ally.rb:47, user_queries.rb:78-104).

partnerStatusWhat rendersControl
activeBordered pill"End partnership" — opens an inline confirm
none, no active partner elsewhereBordered pill"Ask to partner" — opens an inline confirm
none, another ally is the active partnerBare text, no border"One partner at a time" — opens an inline panel naming the current partner with a "Jump to {FirstName}" action that scrolls to and focuses that row
pending(no second line)

hasActivePartner (AlliesPage.vue:486) is derived from partnerStatus === 'active' only, never from pendingSocial::SendPartnerRequest's require_no_pending_request guard is scoped to a single UserAlly record, so it doesn't block a second outgoing request the way an active partnership does. Blocking the UI on pending would invent a rule the backend doesn't enforce.

Ending a partnership — the confirm reads: "End your partnership with {FirstName}? Your mutual streak goes back to zero — your own streak and XP stay put." Matches the Constraints above: only the mutual streak zeroes, not individual streaks or XP. Cancel reads "Cancel," not "Keep partner" — ending takes the same number of taps as starting, with no extra confirmation step (no dark patterns).

endPartnership() sets awaitRefetchQueries: true on its mutation (usePartnership.ts:24-40), unlike sendPartnerRequest/acceptPartnerRequest. Without it, the mutation's promise resolves before the allies refetch lands, the row re-renders to its new state out from under the just-focused "End partnership" button, and focus silently falls to <body> instead of the row's new "Ask to partner" control — a real regression caught in PR #2698 review (Roy) and fixed with a regression test asserting document.activeElement.

Accessibility: every trigger and confirm/cap-panel button carries a full-name aria-label (visible copy uses first names only) plus aria-expanded/aria-controls; confirm and cap panels are role="group" with an aria-label restating the question per-ally. All computed tap targets are ≥44px (asserted via tests/unit/helpers/tapTarget.ts, not a class-name match). The disclosure animation honors prefers-reduced-motion with an opacity-only fallback.


Dashboard widget

The accountability partner widget is a compact card on the Dashboard that surfaces a partner's daily check-in status alongside the current user's. When a partner has not checked in, the user can send a single supportive push notification ("nudge") per day. When both partners have checked in, the widget celebrates the mutual effort with a "★ Best together: N days" caption (renders only when all-time best exceeds current streak), a +25 XP indicator, and a visual glow.

The widget renders only for users who have an active accountability partner. Users without a partner see no change to their Dashboard.

Event-driven XP and streak mechanics

When either partner checks in on a habit goal, the goal_tracking.habit_checked_in domain event fires. The gamification event subscriber invokes Gamification::AwardPartnerBonus synchronously (not a nightly job):

  1. Both partners receive +25 XP (GamificationService.award_xp)
  2. The mutual streak increments (or resets to 1 if broken)
  3. An all-time longest streak (longest_mutual_streak) is tracked per partnership
  4. If the streak hits a milestone tier (7, 30, 100, or 365 days), both partners receive a celebration notification

This is fully idempotent — if the interaction runs twice on the same day for the same partnership, only one bonus is awarded via last_partner_bonus_date guard.

Backend-complete as of v3.9.109 (OBJ-282)

The permanent { enabled: false } query gate on usePartnerStatus.ts was removed when OBJ-282 shipped (PR #604). PartnerWidget now renders for all users with an active accountability partner. All AccountabilityPartnerType fields (partnerCheckedInToday, userCheckedInToday, nudgeSentToday, mutualStreakLastDate, longestMutualStreak) and the SendPartnerNudge mutation are implemented.


Widget states

StateWhat the user sees
Both checked inCard glows (ring-2 ring-primary/30), "+25 XP" badge appears
Current streak < all-time bestMutual streak count + conditional "★ Best together: [N] days" (all-time best) in muted text
Partner not checked in"Give [Name] a boost" button visible
Nudge already sent todayButton disabled, reads "Nudge sent ✓"
User not checked inPrompt: "Your turn — check in to keep the streak alive."
No partnerWidget does not render

Streak milestone celebrations

When a partnership's mutual streak reaches a milestone tier (7, 30, 100, or 365 days), both partners receive a one-shot push notification celebrating the achievement.

Milestone tiers

MilestoneNotificationDelivered to
7 days"You and [Partner] hit a 7-day streak — that's compounding."Both partners
30 days"You and [Partner] hit a 30-day streak — that's compounding."Both partners
100 days"You and [Partner] hit a 100-day streak — that's compounding."Both partners
365 days"You and [Partner] hit a 365-day streak — that's compounding."Both partners

Notification kind

Kind: partner_mutual_streak_milestone (Enum value: 17)

Fires when: new_streak == milestone_tier && previous_streak == new_streak - 1 (prevents duplicate notifications if the interaction reruns)

Template: UserNotification::PARTNER_MUTUAL_STREAK_MILESTONE_TEXT (rails_api/app/models/user_notification.rb:49)

Details hash:

json
{
  "streak": 7,
  "partner_name": "Alex"
}

Source: Gamification::AwardPartnerBonus#fire_milestone_notifications (rails_api/app/interactions/gamification/award_partner_bonus.rb:62–75)


Component: PartnerWidget.vue

Path: ionic_frontend/src/components/dashboard/PartnerWidget.vue

A props-based <script setup lang="ts"> component. It receives data from the parent (Dashboard) and emits a nudge event — it does not call GraphQL directly.

Props

PropTypeDescription
partnerPartnerPartner data object (see composable below)
nudgeSentTodaybooleanWhether a nudge has already been sent today

Emits

EventPayloadDescription
nudgenoneFired when the user taps "Give [Name] a boost"

Layout

  • Partner avatar (32 px circle, falls back to first-initial letter)
  • Partner full name
  • Mutual streak count with chain-link icon
  • Two status indicator circles — filled when checked in, hollow when pending
  • Action area: nudge button or user prompt, depending on state

Accessibility

  • role="region" with aria-label="Accountability partner"
  • aria-live="polite" on the status indicator row and the user prompt
  • Nudge button sets aria-disabled when already sent

Styling

glass-card rounded-2xl px-5 py-4 animate-slide-up — matches the existing Dashboard card pattern.


Composable: usePartnerStatus

Path: ionic_frontend/src/composables/usePartnerStatus.ts

Return values

NameTypeDescription
partnerComputed<Partner | null>The current user's accountability partner, or null
loadingComputed<boolean>Query loading state
errorComputed<Error | undefined>Query error, if any
nudgeSentTodayComputed<boolean>true if the server reports a nudge was sent today or the user sent one optimistically in this session
sendNudge() => Promise<boolean>Sends the nudge mutation; returns true on success
refetch() => voidRe-runs the partner status query

Partner interface

Exported from the composable file:

ts
interface Partner {
  publicId: string
  firstName: string
  lastName: string
  photo: { imageUrl: string } | null
  mutualStreakCount: number
  longestMutualStreak: number
  mutualStreakLastDate: string | null
  partnerCheckedInToday: boolean
  userCheckedInToday: boolean
  nudgeSentToday: boolean
}

Behavior

  • Uses useQuery(PARTNER_STATUS_QUERY) with fetchPolicy: 'cache-and-network'.
  • sendNudge() sets an optimistic nudgeSentToday flag before the mutation resolves. If the mutation fails, the flag is rolled back and a toast error ("Could not send nudge. Try again.") is shown.
  • On success, a toast confirmation ("Nudge sent!") appears.
  • Calling sendNudge() when nudgeSentToday is already true is a no-op (returns false).
  • Live refresh (Phase 2, OBJ-2880): registers a listener on the shared notificationUpdate stream and calls refetch() when an incoming event's kind is partner_request, partner_accepted, partner_declined, or partner_ended — so a partner accepting or declining updates an already-open Dashboard/Goal screen without a navigate-away-and-back. AlliesPage.vue registers the same listener independently for its own ally-list query. See Notifications § Frontend: useNotificationStream for the substrate this builds on.

GraphQL

File: ionic_frontend/src/constants/graphql/social.js

PARTNER_STATUS_QUERY

graphql
query PartnerStatusQuery {
  accountabilityPartner {
    publicId
    firstName
    lastName
    photo { imageUrl }
    mutualStreakCount
    longestMutualStreak
    mutualStreakLastDate
    partnerCheckedInToday
    userCheckedInToday
    nudgeSentToday
  }
}

SEND_PARTNER_NUDGE_MUTATION

graphql
mutation SendPartnerNudgeMutation($allyPublicId: ID!) {
  sendPartnerNudge(allyPublicId: $allyPublicId) {
    success
    errors
  }
}

Dashboard integration

File: ionic_frontend/src/views/Dashboard.vue

The widget is placed between the TodayActions hub and the "Your Goals" section in both the sparse-user and active-user Dashboard layouts. It is guarded by v-if="partner" so it only renders when the usePartnerStatus composable returns a non-null partner.

The Dashboard destructures partner, nudgeSentToday, and sendNudge from the composable and passes them as props / event handlers to PartnerWidget.


Storybook

File: ionic_frontend/src/components/dashboard/PartnerWidget.stories.ts

Twelve story variants cover major widget states and streak scenarios:

StoryDescription
PartnerPendingPartner has not checked in; nudge button active
BothCheckedInBoth users checked in; +25 XP badge and glow
RecordHigherThanCurrentBoth checked in; all-time best > current streak; shows "★ Best together: N days" caption (not yet user-visible)
RecordEqualsCurrentAll-time best equals current streak; no "Best together" caption shown
RecordLowerThanCurrentAll-time best < current streak (impossible state, but story coverage)
NudgeSentNudge already sent; button disabled
UserPendingUser has not checked in; prompt text shown
LongStreak100+ day mutual streak
NewPartnershipStreak count is 0
NoPhotoPartner has no photo; initial-letter fallback avatar
ZeroStreakWithRecordCurrent streak is 0 but has an all-time record
ThreeDigitStreakAt375px375px viewport rendering of three-digit mutual streak


Last updated: 2026-05-13 | Version: v3.9.109


Lifecycle interactions and GraphQL API added 2026-04-22 (OBJ-20). Frontend invite/accept flow added 2026-04-23 (OBJ-23). Partner streak polish (XP literal fix, longestMutualStreak surface, milestone celebrations) shipped 2026-05-12 (v3.9.106/v3.9.107, OBJ-271/OBJ-272). Dashboard widget and nudge feature completed 2026-05-13 (OBJ-282).


Missed-day alerts

When a partner misses a habit check-in, the hourly cron job sends a push notification to the other partner — once it reaches 19:00 in that other partner's own timezone — giving them the opportunity to send an encouraging nudge.

Cron job

Class: Social::GeneratePartnerMissedDayAlertsJobFile: rails_api/app/jobs/social/generate_partner_missed_day_alerts_job.rbSchedule: Hourly (registered in rails_api/config/cronotab.rb), dispatched every hour anchored to worker boot time (not the top of the hour); each run checks whether it's currently 19:00 in the notified partner's own local timezone (TARGET_HOUR = 19, matched via Time.current.in_time_zone(user.timezone_or_default).hour) and skips otherwise. Because dispatch isn't aligned to the top of the hour, delivery lands within roughly an hour of 19:00 local rather than at an exact instant. Queue: default

Registered in cronotab.rb, in the canonical post-fix form:

ruby
Crono.perform(Social::GeneratePartnerMissedDayAlertsJob).every 1.hour

Hourly Crono lines must not use at: — Crono 2.1.0 raises at boot on sub-day at: ("period should be at least 1 day to use 'at'").

Logic

  1. Query all UserAlly records where accountability_partner: true and status: 'accepted'.
  2. For each partnership, skip unless it is currently 19:00 in the notified partner's own timezone (user.timezone_or_default).
  3. Identify the watched user's habit goals that are:
    • partner_visible (Goal.partner_visible scope) — public (private: false), or private with the owner's opt-in shared_with_partner: true
    • Not completed (completed: false)
    • A habit (has a recurrence_type)
    • Due today (Goal#due_today? — which returns false when already checked in today)
    • Not excluded via AccountabilityPartnerGoalExclusion, if that model exists — see "Goal exclusions (planned)" below
  4. If no missed goals exist, skip.
  5. If the partner already received an unacknowledged partner_missed_day notification today, skip (idempotency).
  6. Pick the missed goal with the longest habit_streak.
  7. Generate one notification to the partner via UserNotification.generate.

Guardrails

  • Max 1 alert per partner per day. Idempotent — re-running the job on the same day produces no duplicates.
  • Soft-delete safe. Default scopes on Goal and UserAlly exclude soft-deleted records.
  • Error handling: rescue StandardError => e captures to Sentry, logs to Rails.logger.error, re-raises so Sidekiq retries apply.

Missed-day notification (partner_missed_day)

Enum value: 13 | Constant: UserNotification::PARTNER_MISSED_DAY_TEXT

Content template:

%s hasn't checked in on %s today. A quick encouragement could help.

Details hash:

json
{
  "partner_public_id": "<public_id of the user who missed>",
  "goal_public_id": "<public_id of the missed goal>"
}

Actions:

json
[{ "id": "encourage", "title": "Encourage" }]

Goal exclusions (planned, not yet shipped)

Not live — there is no AccountabilityPartnerGoalExclusion model or accountability_partner_goal_exclusions table yet (the factory file cites OBJ-19 as the prerequisite). The missed-day job already guards for this: it checks defined?(AccountabilityPartnerGoalExclusion) and treats the exclusion list as empty when the model doesn't exist, so today every due habit goal is eligible for a missed-day alert — no goal can be excluded. The spec suite stubs the class (class_double('AccountabilityPartnerGoalExclusion').as_stubbed_const) to exercise the eventual behavior ahead of the real model landing.

Source of truth (missed-day alerts)

ConcernFile
Job logicrails_api/app/jobs/social/generate_partner_missed_day_alerts_job.rb
Schedulerails_api/config/cronotab.rb
Notification kind + text constantrails_api/app/models/user_notification.rb
RSpec specrails_api/spec/jobs/social/generate_partner_missed_day_alerts_job_spec.rb

Per-goal partner sharing

Lets a goal owner share one specific private-tier goal with their confirmed accountability partner, without changing the goal's visibility tier. Opt-in and off by default — a private goal stays invisible to the partner until the owner explicitly flips it on for that goal, and turning it off is immediate and reversible.

Since v4.61 (Allies-Only Goal Visibility, OBJ-3783–OBJ-3788), a goal's visibility is one of three tiers — public / allies / private — not a public/private boolean. shared_with_partner is an orthogonal overlay that only changes anything on a private-tier goal: a confirmed accountability partner is, by construction, always an accepted ally (UserAlly.with_active_partnership requires accepted), so a partner already sees a public or allies-tier goal through the tier itself, with or without shared_with_partner. See Allies § Goal visibility tiers for the three-tier model.

The visibility rule the partner's view is governed by

A confirmed partner's view into the owner's goals is governed by a single rule, Goal.partner_visible (rails_api/app/models/goal.rb:198):

ruby
scope :partner_visible, -> { where("visibility IN ('public','allies') OR shared_with_partner = true") }
Goal tierPartner sees it?How the owner controls it
publicYes, alwaysNo per-goal opt-out yet — see Goal exclusions (planned, not yet shipped) above
alliesYes, always — through the ally tier, not shared_with_partnerSame control as any other accepted ally; shared_with_partner has no effect on an allies-tier goal
private, not sharedNoDefault state — nothing to do
private, sharedYesOwner toggles sharing on for that goal (below)

partner_visible is the single source of truth consumed identically by two surfaces, so they can never drift out of sync: PublicProfileType#partner_goals (the profile's "Shared with you" section) and Social::GeneratePartnerMissedDayAlertsJob (missed-day alerts — sharing a private habit makes it eligible for a missed-day nudge to the partner, same as a public or allies-only one).

Turning sharing on (owner's side)

Component: GoalPartnerSharingCard.vue (ionic_frontend/src/components/goal/GoalPartnerSharingCard.vue), rendered in the right rail of Goal Detail (ionic_frontend/src/views/Goal.vue) — only when the viewer has an active accountability partner (v-if="activePartner").

  • Private goal: an interactive switch. Off by default. Helper text reads "Only [Partner] sees it — private to everyone else" when off, "[Partner] can see this goal. Turn it off any time" when on.
  • Public goal: no switch — an informational row ("[Partner] already sees this — this goal is public. Make it private to choose who sees it"), since a public goal is already partner-visible with no extra state to toggle.
  • Toggling calls the setGoalPartnerSharing mutation with an optimistic update (Goal.vue's handleTogglePartnerSharing); on failure the switch rolls back and shows a retry affordance.
  • When sharing is on, GoalDetailsCard.vue's privacy line reads "Private · shared with [Partner]" instead of plain "Private" on the same Goal Detail page.

setGoalPartnerSharing mutation

File: rails_api/app/graphql/mutations/set_goal_partner_sharing.rb, backed by GoalTracking::SetGoalPartnerSharing (rails_api/app/interactions/goal_tracking/set_goal_partner_sharing.rb).

graphql
mutation SetGoalPartnerSharing($goalId: ID!, $sharedWithPartner: Boolean!) {
  setGoalPartnerSharing(goalId: $goalId, sharedWithPartner: $sharedWithPartner) {
    goal { publicId sharedWithPartner }
    errors
  }
}

Guards: goal_found (404 if the public_id doesn't resolve), authorized_user (FORBIDDEN unless current_user.id == goal.user_id) — only the goal's owner can change its sharing state, not the partner and not any other ally.

What the partner sees

View: PublicProfile.vue's "Shared with you" section, rendered only when profile.viewerIsAccountabilityPartner is true (i.e. the viewer is a confirmed active partner of the profile owner — false for anonymous visitors, non-partner allies, blocked relationships, and the owner viewing their own profile).

  • Sourced from the partnerGoals field on PublicProfileType, which returns [] unless the viewer is a confirmed partner, and otherwise the owner's active (completed: false) partner_visible goals — public, allies, and shared-private together, per the scope above.
  • Since v4.61, the profile renders three mutually-exclusive sections in widest-audience-first order: "Public goals" → "Allies only" (behind goal_allies_only_visibility) → "Shared with you". Each section subtracts any goal already shown in a wider one, so "Shared with you" ends up showing only the private-tier, shared_with_partner: true subset in practice, each marked "Shared privately" with a lock icon. A goal that is both allies-tier and shared_with_partner: true renders once, in "Allies only" — the wider tier wins the overlap, so "Shared with you" never mislabels a goal every ally can already see as "Shared privately." See ionic_frontend/src/views/PublicProfile.vue's allySection/sharedGoals computeds.
  • Completed goals never appear here, shared or not.

Reading a shared-private goal's detail view

A shared-private goal is read-only for the partner: clicking a "Shared with you" card, or tapping a partner_missed_day notification, both route to /public_goal/:goalId (Resolvers::GoalQueries#public_goal), never the owner-only editable /goal/:goalId route. public_goal(id:) returns the goal when it's fully public or when the authenticated caller is a confirmed active accountability partner of the owner and the goal has shared_with_partner: true; every other case — including any unauthenticated caller — gets a bare NOT FOUND, matching the "doesn't exist" response for a private, unshared goal (no existence leak). goal(id:), the owner's editable view, never honors the partner tier — only the owner (or an admin) can resolve it. This split shipped as the OBJ-1871 read-path fix (PR #1854); see Visibility Tier: Single-Record Read-Path Gotcha for the bug this closed and the general pattern to follow for the next visibility tier.

Source of truth (per-goal sharing)

ConcernFile
partner_visible scope + shared_with_partner columnrails_api/app/models/goal.rb
Mutationrails_api/app/graphql/mutations/set_goal_partner_sharing.rb
Interaction (owner-only guard)rails_api/app/interactions/goal_tracking/set_goal_partner_sharing.rb
partnerGoals / viewerIsAccountabilityPartner fieldsrails_api/app/graphql/types/public_profile_type.rb
Sharing control (Goal Detail)ionic_frontend/src/components/goal/GoalPartnerSharingCard.vue
"Shared with you" (partner's view)ionic_frontend/src/views/PublicProfile.vue
Migrationrails_api/db/migrate/20260720000000_add_shared_with_partner_to_goals.rb

Last updated: 2026-09-15 | Version: v4.61.0 (allies-only goal visibility tier — Goal.partner_visible extended to allies-tier goals, "Allies only" added as a third profile section, OBJ-3783–OBJ-3788; symmetric decline notification + live partner-status refresh, OBJ-2880, PR #2617; per-goal partner sharing, OBJ-1572/OBJ-1584/OBJ-1585, PRs #1644/#1677; public_goal detail read-path fix, OBJ-1871, PR #1854; per-direction nudge enforcement via partner_nudges ledger, OBJ-2343, PR #2232; My Allies partner affordance, cap legibility, and end-partnership entry point, OBJ-3009, PR #2698)

Loading…