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:
- Partnership lifecycle — backend interactions and GraphQL mutations for requesting, accepting, declining, and ending a partnership.
- 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.
| Input | Type | Description |
|---|---|---|
ally_public_id | String | Public ID of the target ally |
current_user | User | Authenticated user |
Guards: require_current_user, require_ally_exists, require_no_active_partnership, require_no_pending_request
Behavior:
- Sets
accountability_partner_requested: trueandaccountability_partner_requested_by_idon theUserAllyrecord. - Generates a
partner_requestnotification for the target ally. - Cross-request auto-accept: if the ally already sent a request to the current user, delegates to
AcceptPartnerRequestand 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.
| Input | Type | Description |
|---|---|---|
ally_public_id | String | Public ID of the requesting ally |
current_user | User | Authenticated user |
Guards: require_current_user, require_pending_request_exists, require_no_active_partnership
Behavior:
- Sets
accountability_partner: true,accountability_partner_since, clears request flags, initializesmutual_streak_count: 0on theUserAllyrecord. - Generates a
partner_acceptednotification for the requester. - Awards +50 XP to each user via
GamificationService.award_xp. - Triggers
UserAction.first_accountability_partnerfor 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.
| Input | Type | Description |
|---|---|---|
ally_public_id | String | Public ID of the requesting ally |
current_user | User | Authenticated user |
Guards: require_current_user, require_pending_request_exists
Behavior:
- Clears
accountability_partner_requestedandaccountability_partner_requested_by_id. Ally relationship is unchanged. - Generates a
partner_declinednotification for the requester (notify_requester, Phase 2/OBJ-2880) — captures the requester's id before the update clears it, mirroringAcceptPartnerRequest. 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.
| Input | Type | Description |
|---|---|---|
ally_public_id | String | Public ID of the partner ally |
current_user | User | Authenticated user |
Guards: require_current_user, require_active_partnership
Behavior:
- Sets
accountability_partner: false, zeroesmutual_streak_count, setspartner_ended_at. - Generates a
partner_endednotification (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
mutation SendPartnerRequest($allyPublicId: ID!) {
sendPartnerRequest(allyPublicId: $allyPublicId) {
userAlly {
publicId
partnerStatus
accountabilityPartner
accountabilityPartnerSince
mutualStreakCount
}
errors
}
}acceptPartnerRequest
mutation AcceptPartnerRequest($allyPublicId: ID!) {
acceptPartnerRequest(allyPublicId: $allyPublicId) {
userAlly {
publicId
partnerStatus
accountabilityPartner
accountabilityPartnerSince
mutualStreakCount
}
errors
}
}declinePartnerRequest
mutation DeclinePartnerRequest($allyPublicId: ID!) {
declinePartnerRequest(allyPublicId: $allyPublicId) {
userAlly {
publicId
partnerStatus
accountabilityPartner
}
errors
}
}endPartnership
mutation EndPartnership($allyPublicId: ID!) {
endPartnership(allyPublicId: $allyPublicId) {
userAlly {
publicId
partnerStatus
accountabilityPartner
}
errors
}
}sendPartnerNudge
Sends a daily supportive nudge to an accountability partner.
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 thepartner_nudgesledger table (see Data model below), not just therequire_no_nudge_todayguard, so it holds even under a race between two concurrent requests from the same sender. - Second call same day, same direction, returns
success: falsewith 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.
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)
| Field | Type | Description |
|---|---|---|
publicId | ID! | Partner user's public identifier |
firstName | String | Partner's given name |
lastName | String | Partner's family name |
username | String | Partner's unique handle |
photo | UserPhotoType | Partner's profile photo |
accountabilityPartnerSince | ISO8601DateTime | When the partnership was established |
mutualStreakCount | Integer! | Current mutual check-in streak |
longestMutualStreak | Integer! | All-time longest mutual streak |
mutualStreakLastDate | ISO8601Date | Date of the most recent mutual bonus award |
partnerCheckedInToday | Boolean! | Whether the partner has checked in today |
userCheckedInToday | Boolean! | Whether the current user has checked in today |
nudgeSentToday | Boolean! | 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:
| Field | Type | Description |
|---|---|---|
accountabilityPartner | Boolean! | Whether this ally is the active partner |
accountabilityPartnerSince | ISO8601DateTime | Partnership start date |
mutualStreakCount | Integer! | Current mutual streak count |
partnerStatus | String! | "none", "pending", or "active" |
Notifications
Six notification kinds (defined in UserNotification::KIND_ENUMS):
| Kind | Enum value | Sent when | Sent to |
|---|---|---|---|
partner_request | 10 | Request sent | Target ally |
partner_accepted | 11 | Request accepted | Original requester |
partner_ended | 12 | Partnership ended | Other partner |
partner_missed_day | 13 | Habit not checked in by day's end | Partner (not the person who missed) |
partner_nudge | 18 | User taps "Give [Name] a boost" button | Partner |
partner_declined | 37 | Request declined | Original 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.
| Column | Type | Default | Description |
|---|---|---|---|
accountability_partner | boolean | false | Whether this ally pair has an active partnership |
accountability_partner_since | datetime | nil | When the partnership was established |
accountability_partner_requested | boolean | false | Whether a partner request is pending |
accountability_partner_requested_by_id | bigint | nil | User ID of the requester |
mutual_streak_count | integer | 0 | Current mutual check-in streak |
longest_mutual_streak | integer | 0 | All-time longest mutual streak |
partner_ended_at | datetime | nil | When the last partnership ended |
Migration: rails_api/db/migrate/20260422000001_add_accountability_partner_to_user_allies.rb
Model scopes (on UserAlly):
with_active_partnership—accepted.where(accountability_partner: true)with_pending_partner_request—accepted.where(accountability_partner_requested: true)find_for_users(user_a, user_b)— finds the acceptedUserAllyrecord 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
| Constant | Type |
|---|---|
SEND_PARTNER_REQUEST_MUTATION | Mutation |
ACCEPT_PARTNER_REQUEST_MUTATION | Mutation |
DECLINE_PARTNER_REQUEST_MUTATION | Mutation |
END_PARTNERSHIP_MUTATION | Mutation |
SEND_PARTNER_NUDGE_MUTATION | Mutation |
PARTNER_STATUS_QUERY | Query |
GET_ACCOUNTABILITY_PARTNER_QUERY | Query |
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).
| Export | Type | Description |
|---|---|---|
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) => void | Sets module-level refs to open AcceptPartnershipModal |
clearPendingRequest() | () => void | Resets modal state |
pendingRequestPublicId | Ref<string | null> | Drives AcceptPartnershipModal visibility |
pendingRequestName | Ref<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 / endLoading | Ref<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:
- Modal opens, fetches
USER_ALLIES_WITH_PARTNER_STATUS_QUERYfor the current user. - Allies with
partnerStatus !== 'none'are filtered out. - User taps an ally row — inline confirmation panel expands: "Invite [Name] as your accountability partner for [Goal]?"
- User taps "Send Invite" — loading spinner, then success toast ("Partner request sent.") and auto-close.
- 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:
- Displays: "[Name] wants to be your accountability partner. Hold each other to your goals and build a streak together."
- Two actions: "Let's do it!" (accept) and "Decline".
- 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:
| State | What renders |
|---|---|
| Active partner exists | Badge: "Partnered with [Name]" with avatar |
| Pending request sent | Badge: "Request pending…" (amber) |
| No partner, no pending | Button: "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:
| Condition | Copy |
|---|---|
| No partner, no open request | One 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 partner | A 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).
partnerStatus | What renders | Control |
|---|---|---|
active | Bordered pill | "End partnership" — opens an inline confirm |
none, no active partner elsewhere | Bordered pill | "Ask to partner" — opens an inline confirm |
none, another ally is the active partner | Bare 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 pending — Social::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):
- Both partners receive +25 XP (
GamificationService.award_xp) - The mutual streak increments (or resets to 1 if broken)
- An all-time longest streak (
longest_mutual_streak) is tracked per partnership - 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
| State | What the user sees |
|---|---|
| Both checked in | Card glows (ring-2 ring-primary/30), "+25 XP" badge appears |
| Current streak < all-time best | Mutual 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 today | Button disabled, reads "Nudge sent ✓" |
| User not checked in | Prompt: "Your turn — check in to keep the streak alive." |
| No partner | Widget 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
| Milestone | Notification | Delivered 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:
{
"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
| Prop | Type | Description |
|---|---|---|
partner | Partner | Partner data object (see composable below) |
nudgeSentToday | boolean | Whether a nudge has already been sent today |
Emits
| Event | Payload | Description |
|---|---|---|
nudge | none | Fired 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"witharia-label="Accountability partner"aria-live="polite"on the status indicator row and the user prompt- Nudge button sets
aria-disabledwhen 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
| Name | Type | Description |
|---|---|---|
partner | Computed<Partner | null> | The current user's accountability partner, or null |
loading | Computed<boolean> | Query loading state |
error | Computed<Error | undefined> | Query error, if any |
nudgeSentToday | Computed<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 | () => void | Re-runs the partner status query |
Partner interface
Exported from the composable file:
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)withfetchPolicy: 'cache-and-network'. sendNudge()sets an optimisticnudgeSentTodayflag 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()whennudgeSentTodayis alreadytrueis a no-op (returnsfalse). - Live refresh (Phase 2, OBJ-2880): registers a listener on the shared
notificationUpdatestream and callsrefetch()when an incoming event's kind ispartner_request,partner_accepted,partner_declined, orpartner_ended— so a partner accepting or declining updates an already-open Dashboard/Goal screen without a navigate-away-and-back.AlliesPage.vueregisters 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
query PartnerStatusQuery {
accountabilityPartner {
publicId
firstName
lastName
photo { imageUrl }
mutualStreakCount
longestMutualStreak
mutualStreakLastDate
partnerCheckedInToday
userCheckedInToday
nudgeSentToday
}
}SEND_PARTNER_NUDGE_MUTATION
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:
| Story | Description |
|---|---|
PartnerPending | Partner has not checked in; nudge button active |
BothCheckedIn | Both users checked in; +25 XP badge and glow |
RecordHigherThanCurrent | Both checked in; all-time best > current streak; shows "★ Best together: N days" caption (not yet user-visible) |
RecordEqualsCurrent | All-time best equals current streak; no "Best together" caption shown |
RecordLowerThanCurrent | All-time best < current streak (impossible state, but story coverage) |
NudgeSent | Nudge already sent; button disabled |
UserPending | User has not checked in; prompt text shown |
LongStreak | 100+ day mutual streak |
NewPartnership | Streak count is 0 |
NoPhoto | Partner has no photo; initial-letter fallback avatar |
ZeroStreakWithRecord | Current streak is 0 but has an all-time record |
ThreeDigitStreakAt375px | 375px viewport rendering of three-digit mutual streak |
Related docs
- Accountability Partners PRD — original product rationale and design exploration
- Allies — the underlying ally connection model
- Communities — community spaces and the ally-invite post-create flow
- Dashboard — full Dashboard layout and sections
- Notifications — push notification kinds, including partner nudge delivery
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:
Crono.perform(Social::GeneratePartnerMissedDayAlertsJob).every 1.hourHourly 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
- Query all
UserAllyrecords whereaccountability_partner: trueandstatus: 'accepted'. - For each partnership, skip unless it is currently 19:00 in the notified partner's own timezone (
user.timezone_or_default). - Identify the watched user's habit goals that are:
partner_visible(Goal.partner_visiblescope) — public (private: false), or private with the owner's opt-inshared_with_partner: true- Not completed (
completed: false) - A habit (has a
recurrence_type) - Due today (
Goal#due_today?— which returnsfalsewhen already checked in today) - Not excluded via
AccountabilityPartnerGoalExclusion, if that model exists — see "Goal exclusions (planned)" below
- If no missed goals exist, skip.
- If the partner already received an unacknowledged
partner_missed_daynotification today, skip (idempotency). - Pick the missed goal with the longest
habit_streak. - 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
GoalandUserAllyexclude soft-deleted records. - Error handling:
rescue StandardError => ecaptures to Sentry, logs toRails.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:
{
"partner_public_id": "<public_id of the user who missed>",
"goal_public_id": "<public_id of the missed goal>"
}Actions:
[{ "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)
| Concern | File |
|---|---|
| Job logic | rails_api/app/jobs/social/generate_partner_missed_day_alerts_job.rb |
| Schedule | rails_api/config/cronotab.rb |
| Notification kind + text constant | rails_api/app/models/user_notification.rb |
| RSpec spec | rails_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):
scope :partner_visible, -> { where("visibility IN ('public','allies') OR shared_with_partner = true") }| Goal tier | Partner sees it? | How the owner controls it |
|---|---|---|
public | Yes, always | No per-goal opt-out yet — see Goal exclusions (planned, not yet shipped) above |
allies | Yes, always — through the ally tier, not shared_with_partner | Same control as any other accepted ally; shared_with_partner has no effect on an allies-tier goal |
private, not shared | No | Default state — nothing to do |
private, shared | Yes | Owner 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
setGoalPartnerSharingmutation with an optimistic update (Goal.vue'shandleTogglePartnerSharing); 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).
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
partnerGoalsfield onPublicProfileType, which returns[]unless the viewer is a confirmed partner, and otherwise the owner's active (completed: false)partner_visiblegoals —public,allies, and shared-privatetogether, 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 theprivate-tier,shared_with_partner: truesubset in practice, each marked "Shared privately" with a lock icon. A goal that is bothallies-tier andshared_with_partner: truerenders 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." Seeionic_frontend/src/views/PublicProfile.vue'sallySection/sharedGoalscomputeds. - 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)
| Concern | File |
|---|---|
partner_visible scope + shared_with_partner column | rails_api/app/models/goal.rb |
| Mutation | rails_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 fields | rails_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 |
| Migration | rails_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)