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 import FastAPI, HTTPException, Response
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional, List
import requests import requests
# Allow importing peregrine scripts for cover letter generation # Allow importing peregrine scripts for cover letter generation
@ -901,3 +901,110 @@ def config_user():
return {"name": cfg.get("name", "")} return {"name": cfg.get("name", "")}
except Exception: except Exception:
return {"name": ""} 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>