feat(settings): My Profile tab — store, view, API endpoints

- Add useProfileStore (settings/profile) with load/save, all profile fields,
  loading/saving/saveError state, and graceful resume sync-identity call
- Add MyProfileView.vue: Identity, Mission & Values, NDA Companies, and
  Research Brief Preferences sections; autosave on NDA add/remove and
  debounced autosave (400ms) on research checkbox changes
- Add GET/PUT /api/settings/profile endpoints to dev-api.py with YAML
  field mapping (linkedin ↔ linkedin_url, candidate_*_focus ↔ *_focus,
  mission_preferences dict ↔ list of {industry, note})
- 3 new store tests pass; full suite 26/26 green
This commit is contained in:
pyr0ball 2026-03-21 02:28:14 -07:00
parent 81b87a750c
commit b8eb2a3890
4 changed files with 800 additions and 1 deletions

View file

@ -17,7 +17,7 @@ from pathlib import Path
from fastapi import FastAPI, HTTPException, Response
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import Optional
from typing import Optional, List
import requests
# Allow importing peregrine scripts for cover letter generation
@ -901,3 +901,110 @@ def config_user():
return {"name": cfg.get("name", "")}
except Exception:
return {"name": ""}
# ── Settings: My Profile endpoints ───────────────────────────────────────────
def _user_yaml_path() -> str:
"""Resolve user.yaml path, falling back to legacy location."""
cfg_path = os.path.join(os.path.dirname(DB_PATH), "config", "user.yaml")
if not os.path.exists(cfg_path):
cfg_path = "/devl/job-seeker/config/user.yaml"
return cfg_path
def _read_user_yaml() -> dict:
import yaml
path = _user_yaml_path()
if not os.path.exists(path):
return {}
with open(path) as f:
return yaml.safe_load(f) or {}
def _write_user_yaml(data: dict) -> None:
import yaml
path = _user_yaml_path()
with open(path, "w") as f:
yaml.dump(data, f, default_flow_style=False, allow_unicode=True)
def _mission_dict_to_list(prefs: object) -> list:
"""Convert {industry: note} dict to [{industry, note}] list for the SPA."""
if isinstance(prefs, list):
return prefs
if isinstance(prefs, dict):
return [{"industry": k, "note": v or ""} for k, v in prefs.items()]
return []
def _mission_list_to_dict(prefs: list) -> dict:
"""Convert [{industry, note}] list from the SPA back to {industry: note} dict."""
result = {}
for item in prefs:
if isinstance(item, dict):
result[item.get("industry", "")] = item.get("note", "")
return result
@app.get("/api/settings/profile")
def get_profile():
try:
cfg = _read_user_yaml()
return {
"name": cfg.get("name", ""),
"email": cfg.get("email", ""),
"phone": cfg.get("phone", ""),
"linkedin_url": cfg.get("linkedin", ""),
"career_summary": cfg.get("career_summary", ""),
"candidate_voice": cfg.get("candidate_voice", ""),
"inference_profile": cfg.get("inference_profile", "cpu"),
"mission_preferences": _mission_dict_to_list(cfg.get("mission_preferences", {})),
"nda_companies": cfg.get("nda_companies", []),
"accessibility_focus": cfg.get("candidate_accessibility_focus", False),
"lgbtq_focus": cfg.get("candidate_lgbtq_focus", False),
}
except Exception as e:
raise HTTPException(500, f"Could not read profile: {e}")
class MissionPrefModel(BaseModel):
industry: str
note: str = ""
class UserProfilePayload(BaseModel):
name: str = ""
email: str = ""
phone: str = ""
linkedin_url: str = ""
career_summary: str = ""
candidate_voice: str = ""
inference_profile: str = "cpu"
mission_preferences: List[MissionPrefModel] = []
nda_companies: List[str] = []
accessibility_focus: bool = False
lgbtq_focus: bool = False
@app.put("/api/settings/profile")
def save_profile(payload: UserProfilePayload):
try:
cfg = _read_user_yaml()
cfg["name"] = payload.name
cfg["email"] = payload.email
cfg["phone"] = payload.phone
cfg["linkedin"] = payload.linkedin_url
cfg["career_summary"] = payload.career_summary
cfg["candidate_voice"] = payload.candidate_voice
cfg["inference_profile"] = payload.inference_profile
cfg["mission_preferences"] = _mission_list_to_dict(
[m.model_dump() for m in payload.mission_preferences]
)
cfg["nda_companies"] = payload.nda_companies
cfg["candidate_accessibility_focus"] = payload.accessibility_focus
cfg["candidate_lgbtq_focus"] = payload.lgbtq_focus
_write_user_yaml(cfg)
return {"ok": True}
except Exception as e:
raise HTTPException(500, f"Could not save profile: {e}")

View file

@ -0,0 +1,40 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useProfileStore } from './profile'
vi.mock('../../composables/useApi', () => ({ useApiFetch: vi.fn() }))
import { useApiFetch } from '../../composables/useApi'
const mockFetch = vi.mocked(useApiFetch)
describe('useProfileStore', () => {
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks() })
it('load() populates fields from API', async () => {
mockFetch.mockResolvedValue({
data: { name: 'Meg', email: 'meg@example.com', phone: '555-0100',
linkedin_url: '', career_summary: '', candidate_voice: '',
inference_profile: 'cpu', mission_preferences: [],
nda_companies: [], accessibility_focus: false, lgbtq_focus: false },
error: null,
})
const store = useProfileStore()
await store.load()
expect(store.name).toBe('Meg')
expect(store.email).toBe('meg@example.com')
})
it('save() calls PUT /api/settings/profile', async () => {
mockFetch.mockResolvedValue({ data: { ok: true }, error: null })
const store = useProfileStore()
store.name = 'Meg'
await store.save()
expect(mockFetch).toHaveBeenCalledWith('/api/settings/profile', expect.objectContaining({ method: 'PUT' }))
})
it('save() error sets error state', async () => {
mockFetch.mockResolvedValue({ data: null, error: { kind: 'network', message: 'fail' } })
const store = useProfileStore()
await store.save()
expect(store.saveError).toBeTruthy()
})
})

View file

@ -0,0 +1,87 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useApiFetch } from '../../composables/useApi'
export interface MissionPref { industry: string; note: string }
export const useProfileStore = defineStore('settings/profile', () => {
const name = ref('')
const email = ref('')
const phone = ref('')
const linkedin_url = ref('')
const career_summary = ref('')
const candidate_voice = ref('')
const inference_profile = ref('cpu')
const mission_preferences = ref<MissionPref[]>([])
const nda_companies = ref<string[]>([])
const accessibility_focus = ref(false)
const lgbtq_focus = ref(false)
const loading = ref(false)
const saving = ref(false)
const saveError = ref<string | null>(null)
async function load() {
loading.value = true
const { data } = await useApiFetch<Record<string, unknown>>('/api/settings/profile')
loading.value = false
if (!data) return
name.value = String(data.name ?? '')
email.value = String(data.email ?? '')
phone.value = String(data.phone ?? '')
linkedin_url.value = String(data.linkedin_url ?? '')
career_summary.value = String(data.career_summary ?? '')
candidate_voice.value = String(data.candidate_voice ?? '')
inference_profile.value = String(data.inference_profile ?? 'cpu')
mission_preferences.value = (data.mission_preferences as MissionPref[]) ?? []
nda_companies.value = (data.nda_companies as string[]) ?? []
accessibility_focus.value = Boolean(data.accessibility_focus)
lgbtq_focus.value = Boolean(data.lgbtq_focus)
}
async function save() {
saving.value = true
saveError.value = null
const body = {
name: name.value,
email: email.value,
phone: phone.value,
linkedin_url: linkedin_url.value,
career_summary: career_summary.value,
candidate_voice: candidate_voice.value,
inference_profile: inference_profile.value,
mission_preferences: mission_preferences.value,
nda_companies: nda_companies.value,
accessibility_focus: accessibility_focus.value,
lgbtq_focus: lgbtq_focus.value,
}
const { error } = await useApiFetch('/api/settings/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
saving.value = false
if (error) {
saveError.value = 'Save failed — please try again.'
return
}
// Push identity fields to resume YAML — graceful; endpoint may not exist yet (Task 3)
await useApiFetch('/api/settings/resume/sync-identity', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.value,
email: email.value,
phone: phone.value,
linkedin_url: linkedin_url.value,
}),
})
}
return {
name, email, phone, linkedin_url, career_summary, candidate_voice, inference_profile,
mission_preferences, nda_companies, accessibility_focus, lgbtq_focus,
loading, saving, saveError,
load, save,
}
})

View file

@ -0,0 +1,565 @@
<template>
<div class="my-profile">
<header class="page-header">
<h2>My Profile</h2>
<p class="subtitle">Your identity and preferences used for cover letters, research, and interview prep.</p>
</header>
<div v-if="store.loading" class="loading-state">Loading profile</div>
<template v-else>
<!-- Identity -->
<section class="form-section">
<h3 class="section-title">Identity</h3>
<div class="field-row">
<label class="field-label" for="profile-name">Full name</label>
<input id="profile-name" v-model="store.name" type="text" class="text-input" placeholder="Your Name" />
</div>
<div class="field-row">
<label class="field-label" for="profile-email">Email</label>
<input id="profile-email" v-model="store.email" type="email" class="text-input" placeholder="you@example.com" />
</div>
<div class="field-row">
<label class="field-label" for="profile-phone">Phone</label>
<input id="profile-phone" v-model="store.phone" type="tel" class="text-input" placeholder="555-000-0000" />
</div>
<div class="field-row">
<label class="field-label" for="profile-linkedin">LinkedIn URL</label>
<input id="profile-linkedin" v-model="store.linkedin_url" type="url" class="text-input" placeholder="linkedin.com/in/yourprofile" />
</div>
<div class="field-row field-row--stacked">
<label class="field-label" for="profile-summary">Career summary</label>
<textarea
id="profile-summary"
v-model="store.career_summary"
class="text-area"
rows="5"
placeholder="23 sentences summarising your experience and focus."
/>
<button
v-if="config.tier !== 'free'"
class="btn-generate"
type="button"
@click="generateSummary"
:disabled="generatingSummary"
>{{ generatingSummary ? 'Generating…' : 'Generate ✦' }}</button>
</div>
<div class="field-row field-row--stacked">
<label class="field-label" for="profile-voice">Candidate voice</label>
<textarea
id="profile-voice"
v-model="store.candidate_voice"
class="text-area"
rows="3"
placeholder="How you write and communicate — used to shape cover letter voice."
/>
</div>
<div class="field-row">
<label class="field-label" for="profile-inference">Inference profile</label>
<select id="profile-inference" v-model="store.inference_profile" class="select-input">
<option value="remote">Remote</option>
<option value="cpu">CPU</option>
<option value="single-gpu">Single GPU</option>
<option value="dual-gpu">Dual GPU</option>
</select>
</div>
<div class="save-row">
<button class="btn-save" type="button" @click="store.save()" :disabled="store.saving">
{{ store.saving ? 'Saving…' : 'Save Identity' }}
</button>
<p v-if="store.saveError" class="error-msg">{{ store.saveError }}</p>
</div>
</section>
<!-- Mission & Values -->
<section class="form-section">
<h3 class="section-title">Mission &amp; Values</h3>
<p class="section-desc">
Industries you care about. When a job matches, the cover letter includes your personal alignment note.
</p>
<div
v-for="(pref, idx) in store.mission_preferences"
:key="idx"
class="mission-row"
>
<input
v-model="pref.industry"
type="text"
class="text-input mission-industry"
placeholder="Industry (e.g. music)"
/>
<input
v-model="pref.note"
type="text"
class="text-input mission-note"
placeholder="Your personal note (optional)"
/>
<button class="btn-remove" type="button" @click="removeMission(idx)" aria-label="Remove">×</button>
</div>
<div class="mission-actions">
<button class="btn-secondary" type="button" @click="addMission">+ Add mission</button>
<button
v-if="config.tier !== 'free'"
class="btn-generate"
type="button"
@click="generateMissions"
:disabled="generatingMissions"
>{{ generatingMissions ? 'Generating…' : 'Generate ✦' }}</button>
</div>
<div class="save-row">
<button class="btn-save" type="button" @click="store.save()" :disabled="store.saving">
{{ store.saving ? 'Saving…' : 'Save Mission' }}
</button>
<p v-if="store.saveError" class="error-msg">{{ store.saveError }}</p>
</div>
</section>
<!-- NDA Companies -->
<section class="form-section">
<h3 class="section-title">NDA Companies</h3>
<p class="section-desc">
Companies you can't name. They appear as "previous employer (NDA)" in research briefs when match score is low.
</p>
<div class="tag-list">
<span
v-for="(company, idx) in store.nda_companies"
:key="idx"
class="tag"
>
{{ company }}
<button class="tag-remove" type="button" @click="removeNda(idx)" :aria-label="`Remove ${company}`">×</button>
</span>
</div>
<div class="nda-add-row">
<input
v-model="newNdaCompany"
type="text"
class="text-input nda-input"
placeholder="Company name"
@keydown.enter.prevent="addNda"
/>
<button class="btn-secondary" type="button" @click="addNda" :disabled="!newNdaCompany.trim()">Add</button>
</div>
</section>
<!-- Research Brief Preferences -->
<section class="form-section">
<h3 class="section-title">Research Brief Preferences</h3>
<p class="section-desc">
Optional sections added to company briefs for your personal decision-making only.
These details are never included in applications.
</p>
<div class="checkbox-row">
<input
id="pref-accessibility"
v-model="store.accessibility_focus"
type="checkbox"
class="checkbox"
@change="autosave"
/>
<label for="pref-accessibility" class="checkbox-label">
Include accessibility &amp; inclusion research in company briefs
</label>
</div>
<div class="checkbox-row">
<input
id="pref-lgbtq"
v-model="store.lgbtq_focus"
type="checkbox"
class="checkbox"
@change="autosave"
/>
<label for="pref-lgbtq" class="checkbox-label">
Include LGBTQ+ inclusion research in company briefs
</label>
</div>
</section>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useProfileStore } from '../../stores/settings/profile'
import { useAppConfigStore } from '../../stores/appConfig'
const store = useProfileStore()
const config = useAppConfigStore()
const newNdaCompany = ref('')
const generatingSummary = ref(false)
const generatingMissions = ref(false)
onMounted(() => { store.load() })
// Mission helpers
function addMission() {
store.mission_preferences = [...store.mission_preferences, { industry: '', note: '' }]
}
function removeMission(idx: number) {
store.mission_preferences = store.mission_preferences.filter((_, i) => i !== idx)
}
// NDA helpers (autosave on add/remove)
function addNda() {
const val = newNdaCompany.value.trim()
if (!val) return
store.nda_companies = [...store.nda_companies, val]
newNdaCompany.value = ''
store.save()
}
function removeNda(idx: number) {
store.nda_companies = store.nda_companies.filter((_, i) => i !== idx)
store.save()
}
// Research prefs autosave (debounced 400ms)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
function autosave() {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => store.save(), 400)
}
// AI generation (paid tier)
async function generateSummary() {
generatingSummary.value = true
try {
const res = await fetch('/api/settings/profile/generate-summary', { method: 'POST' })
if (res.ok) {
const data = await res.json() as { summary?: string }
if (data.summary) store.career_summary = data.summary
}
} finally {
generatingSummary.value = false
}
}
async function generateMissions() {
generatingMissions.value = true
try {
const res = await fetch('/api/settings/profile/generate-missions', { method: 'POST' })
if (res.ok) {
const data = await res.json() as { mission_preferences?: Array<{ industry: string; note: string }> }
if (data.mission_preferences) store.mission_preferences = data.mission_preferences
}
} finally {
generatingMissions.value = false
}
}
</script>
<style scoped>
.my-profile {
max-width: 680px;
}
.page-header {
margin-bottom: var(--space-6);
}
.page-header h2 {
margin: 0 0 var(--space-1);
font-size: 1.25rem;
font-weight: 600;
}
.subtitle {
margin: 0;
color: var(--color-text-muted);
font-size: 0.875rem;
}
.loading-state {
color: var(--color-text-muted);
font-size: 0.875rem;
padding: var(--space-4) 0;
}
/* ── Sections ──────────────────────────────────────────── */
.form-section {
border: 1px solid var(--color-border);
border-radius: 8px;
padding: var(--space-5);
margin-bottom: var(--space-5);
}
.section-title {
margin: 0 0 var(--space-3);
font-size: 0.9rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
}
.section-desc {
margin: calc(-1 * var(--space-2)) 0 var(--space-4);
font-size: 0.8rem;
color: var(--color-text-muted);
line-height: 1.5;
}
/* ── Fields ───────────────────────────────────────────── */
.field-row {
display: grid;
grid-template-columns: 160px 1fr;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.field-row--stacked {
grid-template-columns: 1fr;
align-items: flex-start;
}
.field-row--stacked .field-label {
margin-bottom: var(--space-1);
}
.field-label {
font-size: 0.825rem;
color: var(--color-text-muted);
font-weight: 500;
}
.text-input,
.select-input {
width: 100%;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface-raised, var(--color-surface));
color: var(--color-text);
font-size: 0.875rem;
box-sizing: border-box;
}
.text-input:focus,
.select-input:focus,
.text-area:focus {
outline: 2px solid var(--color-primary);
outline-offset: -1px;
border-color: transparent;
}
.text-area {
width: 100%;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface-raised, var(--color-surface));
color: var(--color-text);
font-size: 0.875rem;
resize: vertical;
font-family: inherit;
box-sizing: border-box;
}
/* ── Save row ─────────────────────────────────────────── */
.save-row {
display: flex;
align-items: center;
gap: var(--space-3);
margin-top: var(--space-4);
padding-top: var(--space-4);
border-top: 1px solid var(--color-border);
}
.btn-save {
padding: var(--space-2) var(--space-5);
background: var(--color-primary);
color: var(--color-on-primary, #fff);
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
}
.btn-save:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-msg {
margin: 0;
color: var(--color-danger, #c0392b);
font-size: 0.825rem;
}
.btn-generate {
padding: var(--space-2) var(--space-3);
background: transparent;
border: 1px solid var(--color-primary);
color: var(--color-primary);
border-radius: 6px;
font-size: 0.8rem;
cursor: pointer;
margin-top: var(--space-2);
align-self: flex-start;
}
.btn-generate:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
padding: var(--space-2) var(--space-3);
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
border-radius: 6px;
font-size: 0.8rem;
cursor: pointer;
}
.btn-secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── Mission rows ─────────────────────────────────────── */
.mission-row {
display: grid;
grid-template-columns: 1fr 2fr auto;
gap: var(--space-2);
margin-bottom: var(--space-2);
align-items: center;
}
.mission-actions {
display: flex;
gap: var(--space-2);
margin-top: var(--space-2);
}
.btn-remove {
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-muted);
border-radius: 4px;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 1rem;
line-height: 1;
}
.btn-remove:hover {
border-color: var(--color-danger, #c0392b);
color: var(--color-danger, #c0392b);
}
/* ── NDA tags ─────────────────────────────────────────── */
.tag-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-3);
min-height: 32px;
}
.tag {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
border-radius: 999px;
font-size: 0.8rem;
color: var(--color-text);
}
.tag-remove {
background: transparent;
border: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: 1rem;
line-height: 1;
padding: 0;
display: flex;
align-items: center;
}
.tag-remove:hover {
color: var(--color-danger, #c0392b);
}
.nda-add-row {
display: flex;
gap: var(--space-2);
}
.nda-input {
flex: 1;
}
/* ── Checkboxes ───────────────────────────────────────── */
.checkbox-row {
display: flex;
align-items: flex-start;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.checkbox {
flex-shrink: 0;
margin-top: 2px;
width: 16px;
height: 16px;
accent-color: var(--color-primary);
cursor: pointer;
}
.checkbox-label {
font-size: 0.875rem;
line-height: 1.5;
cursor: pointer;
}
/* ── Mobile ───────────────────────────────────────────── */
@media (max-width: 767px) {
.field-row {
grid-template-columns: 1fr;
}
.mission-row {
grid-template-columns: 1fr auto;
grid-template-rows: auto auto;
}
.mission-note {
grid-column: 1;
}
.btn-remove {
grid-row: 1;
grid-column: 2;
align-self: start;
}
}
</style>