diff --git a/.gitignore b/.gitignore index 8d221f6..daeb0d7 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,9 @@ dist/ build/ .vite/ +# Local-only Personal Mode state and generated credentials +.careeros/ + # Supabase local/temporary state .supabase/ supabase/.temp/ diff --git a/README.md b/README.md index fbe5ff2..38410ee 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,9 @@ hackathons, scholarships, and other resume-building opportunities; compares them detailed saved profile; explains where the student is competitive and where they are not; and tracks the full lifecycle from "found it" to "applied" to "interviewed." -Phase 0 is implemented. Phase 1A currently provides routed manual profile, primary-education, -and experience/research workflows: a React/TypeScript/Vite frontend, a local Supabase stack +Phase 2A is implemented as a local personal workspace. It provides a one-command Personal Mode, +local Greenhouse discovery, opportunity/application tracking, and routed manual profile, +primary-education, and experience/research workflows: a React/TypeScript/Vite frontend, a local Supabase stack (Postgres/Auth/PostgREST), `profiles`, `education_entries`, and `work_experience` protected by Row Level Security, and CI. Projects, links, skills, preferences, and resume features remain deferred. See [Local development setup](#local-development-setup) below to run it. @@ -57,6 +58,29 @@ matter of enabling sign-ups, not rearchitecting the schema or the pipeline. See ## Local development setup +### Personal Mode — normal daily use + +With Docker or Colima running, use just: + +```bash +npm run personal +``` + +It starts the local stack when needed, applies only pending migrations (never `supabase db reset`), +generates gitignored local runtime configuration, reuses the existing `dev-owner@careeros.local` +identity when present, refreshes the configured Greenhouse sources, and starts CareerOS at +`http://127.0.0.1:5173`. Personal Mode signs that local owner in invisibly; it never shows email +or password UI, and `sign-in` routes back into the workspace. The generated local session +credential lives only in gitignored `.careeros/` and `app/.env.local`; it is not a service-role +credential and public authentication remains dormant/recoverable for a later deployment. + +The page-level **Refresh opportunities** action talks only to the loopback companion started by +this command. It runs the trusted local ingestion CLI without exposing its service-role key to the +browser. Re-running `npm run personal` is safe: existing opportunities, saved state, applications, +interview notes, profile data, and version history are preserved. + +### Advanced maintainer and isolated-test setup + Prerequisites: - Node.js `22.23.1` (the root [.nvmrc](.nvmrc) is authoritative). Run `nvm use` from the @@ -87,7 +111,8 @@ supabase start # bound to 127.0.0.1. ./supabase/scripts/dev-tunnel.sh -# 4. Apply every migration from an empty database +# 4. Apply every migration from an empty database (isolated test setup only; +# never use this during Personal Mode or against personal data) supabase db reset # 5. Create the two local-only development login users (idempotent). diff --git a/app/README.md b/app/README.md index 34ce4cf..25450f5 100644 --- a/app/README.md +++ b/app/README.md @@ -4,8 +4,10 @@ React + TypeScript + Vite single-page app. Talks directly to the local Supabase (Postgres/Auth/PostgREST) — no backend server in this project (see [../docs/ARCHITECTURE.md](../docs/ARCHITECTURE.md), [ADR-002](../docs/adr/ADR-002-frontend-and-backend-boundaries.md)). -See the repository root [README.md](../README.md) for full local setup instructions -(Colima/Docker, Supabase CLI, the loopback dev tunnel). +For normal personal use, run `npm run personal` from the repository root. It writes this app's +gitignored `.env.local`, establishes an invisible local Personal Mode session, refreshes configured +sources, and starts Vite. See the repository root [README.md](../README.md) for the full local +runtime notes and advanced maintainer setup. ## Scripts @@ -23,6 +25,8 @@ See the repository root [README.md](../README.md) for full local setup instructi ## Environment variables -Copy `.env.example` to `.env.local` and fill in the values `supabase status` prints -for the local stack. Only `VITE_`-prefixed variables are exposed to the browser bundle — -never put a secret/service-role key in one. +`npm run personal` creates `.env.local` automatically for the local personal runtime. Only +`VITE_`-prefixed values are exposed to the browser bundle — never put a secret/service-role key in +one. `VITE_PERSONAL_MODE=true` is a local-only convenience boundary: it uses a generated local +owner credential to establish the existing RLS-scoped session without rendering authentication UI. +Set it false or omit it when returning to the dormant public-auth route. diff --git a/app/src/App.test.tsx b/app/src/App.test.tsx index f7487f5..4ffb4c6 100644 --- a/app/src/App.test.tsx +++ b/app/src/App.test.tsx @@ -1,17 +1,20 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { describe, expect, it, vi, beforeEach } from 'vitest' +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import App from './App' const mockGetSession = vi.fn() const mockOnAuthStateChange = vi.fn() const mockMaybeSingle = vi.fn() +const mockSignInWithPassword = vi.fn() vi.mock('./lib/supabaseClient', () => ({ supabase: { auth: { getSession: () => mockGetSession(), onAuthStateChange: (...args: unknown[]) => mockOnAuthStateChange(...args), + signInWithPassword: (...args: unknown[]) => + mockSignInWithPassword(...args), signOut: vi.fn(), }, from: () => ({ @@ -44,16 +47,23 @@ vi.mock('./lib/profileReviewRepository', () => ({ state: () => Promise.resolve({ data: [], error: null }), }, })) +vi.mock('./pages/DashboardPage', () => ({ + DashboardPage: () =>

Dashboard

, +})) describe('App', () => { beforeEach(() => { + vi.stubEnv('VITE_PERSONAL_MODE', 'false') mockGetSession.mockReset() + mockSignInWithPassword.mockReset() mockOnAuthStateChange.mockReturnValue({ data: { subscription: { unsubscribe: vi.fn() } }, }) mockMaybeSingle.mockResolvedValue({ data: null, error: null }) }) + afterEach(() => vi.unstubAllEnvs()) + it('shows the sign-in screen when there is no session (unauthenticated state)', async () => { mockGetSession.mockResolvedValue({ data: { session: null } }) @@ -64,7 +74,7 @@ describe('App', () => { ).toBeInTheDocument() }) - it('shows the profile screen when a session exists (authenticated state)', async () => { + it('shows the dashboard when a session exists (authenticated state)', async () => { mockGetSession.mockResolvedValue({ data: { session: { user: { id: 'user-1' } } }, }) @@ -72,10 +82,34 @@ describe('App', () => { render() expect( - await screen.findByRole('heading', { name: /your profile/i }), + await screen.findByRole('heading', { name: /dashboard/i }), ).toBeInTheDocument() }) + it('uses the local Personal Mode session without rendering a sign-in screen', async () => { + vi.stubEnv('VITE_PERSONAL_MODE', 'true') + vi.stubEnv('VITE_PERSONAL_OWNER_EMAIL', 'dev-owner@careeros.local') + vi.stubEnv('VITE_PERSONAL_OWNER_PASSWORD', 'local-only-password') + mockGetSession.mockResolvedValue({ data: { session: null }, error: null }) + mockSignInWithPassword.mockResolvedValue({ + data: { session: { user: { id: 'user-1' } } }, + error: null, + }) + + render() + + expect( + await screen.findByRole('heading', { name: /dashboard/i }), + ).toBeInTheDocument() + expect( + screen.queryByRole('heading', { name: /sign in/i }), + ).not.toBeInTheDocument() + expect(mockSignInWithPassword).toHaveBeenCalledWith({ + email: 'dev-owner@careeros.local', + password: 'local-only-password', + }) + }) + it('shows a retryable error instead of loading forever when session init returns an error', async () => { mockGetSession.mockResolvedValue({ data: { session: null }, diff --git a/app/src/App.tsx b/app/src/App.tsx index bf77598..377ec04 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -21,6 +21,7 @@ import { PrivateOpportunityDetailPage } from './pages/PrivateOpportunityDetailPa import { ManualOpportunityFormPage } from './pages/ManualOpportunityFormPage' import { ApplicationsListPage } from './pages/ApplicationsListPage' import { ApplicationDetailPage } from './pages/ApplicationDetailPage' +import { DashboardPage } from './pages/DashboardPage' function AuthPending() { const { initError, loading, retryInit } = useAuth() @@ -46,7 +47,7 @@ function AuthPending() { function RootRedirect() { const { session, loading, initError } = useAuth() if (loading || initError) return - return + return } function RequireAuth() { @@ -56,9 +57,10 @@ function RequireAuth() { } function SignInRoute() { - const { session, loading, initError } = useAuth() + const { session, loading, initError, personalMode } = useAuth() if (loading || initError) return - return session ? : + if (personalMode || session) return + return } function NotFoundPage() { @@ -97,6 +99,7 @@ const router = createBrowserRouter([ { element: , children: [ + { path: '/dashboard', element: }, { path: '/profile', element: , diff --git a/app/src/contexts/AuthContext.tsx b/app/src/contexts/AuthContext.tsx index 150534c..931e503 100644 --- a/app/src/contexts/AuthContext.tsx +++ b/app/src/contexts/AuthContext.tsx @@ -14,11 +14,13 @@ interface AuthContextValue { initError: string | null retryInit: () => void signOut: () => Promise<{ error: string | null }> + personalMode: boolean } const AuthContext = createContext(undefined) export function AuthProvider({ children }: { children: ReactNode }) { + const personalMode = import.meta.env.VITE_PERSONAL_MODE === 'true' const [session, setSession] = useState(null) const [loading, setLoading] = useState(true) const [initError, setInitError] = useState(null) @@ -27,26 +29,55 @@ export function AuthProvider({ children }: { children: ReactNode }) { useEffect(() => { let active = true - supabase.auth - .getSession() - .then(({ data, error }) => { - if (!active) return - if (error) { - setInitError('Could not check your sign-in status. Please try again.') - setLoading(false) - return - } + async function initialize() { + const { data, error } = await supabase.auth.getSession() + if (!active) return + if (error) { + setInitError( + 'Could not check your local CareerOS session. Please try again.', + ) + setLoading(false) + return + } + if (data.session || !personalMode) { setSession(data.session) setLoading(false) - }) - .catch(() => { - if (!active) return + return + } + const email = import.meta.env.VITE_PERSONAL_OWNER_EMAIL + const password = import.meta.env.VITE_PERSONAL_OWNER_PASSWORD + if (!email || !password) { setInitError( - 'Could not reach the authentication service. Please try again.', + 'Personal Mode is not ready. Run npm run personal from the repository root.', ) setLoading(false) - }) + return + } + const signIn = await supabase.auth.signInWithPassword({ email, password }) + if (!active) return + if (signIn.error || !signIn.data.session) { + setInitError( + 'Personal Mode could not connect to the local workspace. Run npm run personal and try again.', + ) + setLoading(false) + return + } + setSession(signIn.data.session) + setLoading(false) + } + + void initialize().catch(() => { + if (!active) return + setInitError( + 'Could not reach the local CareerOS workspace. Please try again.', + ) + setLoading(false) + }) + /* + * Public authentication is intentionally left available for a later + * deployment. Personal Mode only establishes a local session invisibly. + */ const { data: { subscription }, } = supabase.auth.onAuthStateChange((_event, newSession) => { @@ -57,7 +88,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { active = false subscription.unsubscribe() } - }, [retryCount]) + }, [retryCount, personalMode]) function retryInit() { setLoading(true) @@ -66,13 +97,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { } async function signOut(): Promise<{ error: string | null }> { + if (personalMode) return { error: null } const { error } = await supabase.auth.signOut() return { error: error ? 'Sign-out failed. Please try again.' : null } } return ( {children} diff --git a/app/src/index.css b/app/src/index.css index 848a0d3..dedca3b 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -1,111 +1,1226 @@ :root { - color-scheme: light dark; - --text: #1f2933; - --bg: #ffffff; - --border: #d1d5db; - --accent: #4f46e5; - --danger: #b91c1c; - font: - 16px/1.5 system-ui, - 'Segoe UI', - Roboto, - sans-serif; + --font-sans: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + 'Segoe UI', sans-serif; + --bg: #f5f7fa; + --bg-subtle: #edf1f5; + --surface: rgba(255, 255, 255, 0.84); + --surface-solid: #fff; + --surface-raised: #fff; + --sidebar: #111722; + --sidebar-muted: #a6b0c2; + --text: #182233; + --text-secondary: #566277; + --text-muted: #7b8798; + --border: #dce2e9; + --border-strong: #c9d2dc; + --accent: #176d61; + --accent-hover: #10594f; + --accent-soft: #e2f2ed; + --positive: #197755; + --positive-soft: #e6f5ed; + --warning: #a86411; + --warning-soft: #fff3df; + --danger: #b23a47; + --danger-soft: #fcebed; + --info: #2969a8; + --radius-sm: 8px; + --radius-md: 14px; + --radius-lg: 20px; + --radius-xl: 28px; + --shadow-sm: 0 1px 2px rgba(20, 34, 51, 0.04); + --shadow-md: 0 12px 32px rgba(20, 34, 51, 0.08); + --shadow-lg: 0 24px 70px rgba(20, 34, 51, 0.12); + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.25rem; + --space-6: 1.5rem; + --space-8: 2rem; + --space-10: 2.5rem; + --transition: 160ms ease; + color: var(--text); + font: 15px/1.5 var(--font-sans); } @media (prefers-color-scheme: dark) { :root { - --text: #e5e7eb; - --bg: #111827; - --border: #374151; - --accent: #818cf8; - --danger: #f87171; + --bg: #10151f; + --bg-subtle: #171e2a; + --surface: rgba(26, 34, 47, 0.9); + --surface-solid: #1a222f; + --surface-raised: #202a38; + --sidebar: #0a0f17; + --sidebar-muted: #91a0b7; + --text: #eef3f8; + --text-secondary: #bec8d5; + --text-muted: #8f9cad; + --border: #2f3a4a; + --border-strong: #465366; + --accent: #56b6a5; + --accent-hover: #72cdbd; + --accent-soft: #173d3a; + --positive: #62c696; + --positive-soft: #173c2e; + --warning: #f0b95e; + --warning-soft: #493617; + --danger: #f18b96; + --danger-soft: #48232a; + --info: #79b5ed; + --shadow-md: 0 12px 32px rgba(0, 0, 0, 0.2); } } * { box-sizing: border-box; } - +html { + min-width: 320px; + background: var(--bg); +} body { margin: 0; + min-width: 320px; + min-height: 100svh; color: var(--text); - background: var(--bg); + background: + radial-gradient( + circle at 100% 0, + color-mix(in srgb, var(--accent) 9%, transparent), + transparent 28rem + ), + var(--bg); } - -#root { - max-width: 32rem; - margin: 0 auto; - padding: 1.5rem 1rem; - min-height: 100svh; +button, +input, +select, +textarea { + font: inherit; +} +button { + cursor: pointer; +} +button:disabled { + cursor: not-allowed; + opacity: 0.58; +} +a { + color: var(--accent); + text-decoration: none; +} +a:hover { + color: var(--accent-hover); +} +h1, +h2, +h3, +p { + margin-top: 0; } - h1 { - font-size: 1.5rem; - margin: 0 0 1rem; + font-size: clamp(1.75rem, 3vw, 2.45rem); + line-height: 1.12; + letter-spacing: -0.045em; + margin-bottom: var(--space-2); +} +h2 { + font-size: 1.2rem; + letter-spacing: -0.025em; + margin-bottom: var(--space-2); +} +h3 { + font-size: 1rem; +} +small, +.muted { + color: var(--text-muted); +} +.sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; +} +:focus-visible { + outline: 3px solid color-mix(in srgb, var(--accent) 45%, transparent); + outline-offset: 2px; } -form { +.app-shell { + min-height: 100svh; + display: grid; + grid-template-columns: 242px minmax(0, 1fr); +} +.sidebar { + position: sticky; + top: 0; display: flex; flex-direction: column; - gap: 0.75rem; - max-width: 24rem; + height: 100svh; + padding: 24px 14px 18px; + color: #f4f7fb; + background: var(--sidebar); } - -label { +.brand { + display: flex; + align-items: center; + gap: 10px; + padding: 4px 10px 27px; + color: #fff; + font-size: 1.1rem; + font-weight: 750; + letter-spacing: -0.03em; +} +.brand:hover { + color: #fff; +} +.brand-mark { + display: grid; + place-items: center; + width: 30px; + height: 30px; + border-radius: 10px; + color: #10211f; + background: #91d9c9; + font: + 800 16px Georgia, + serif; +} +.primary-nav { + display: grid; + gap: 4px; +} +.primary-nav a { + padding: 10px 12px; + border-radius: var(--radius-sm); + color: var(--sidebar-muted); font-weight: 600; - font-size: 0.9rem; + transition: + background var(--transition), + color var(--transition); +} +.primary-nav a:hover, +.primary-nav a.active { + color: #fff; + background: rgba(255, 255, 255, 0.09); +} +.sidebar-footer { + display: grid; + gap: 8px; + margin-top: auto; + padding: 12px 10px; + border-top: 1px solid rgba(255, 255, 255, 0.11); +} +.personal-indicator { + color: var(--sidebar-muted); + font-size: 0.78rem; +} +.app-content { + width: min(1500px, 100%); + padding: 42px clamp(22px, 4vw, 62px) 70px; + overflow: hidden; +} +.page { + width: 100%; +} +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-5); + margin-bottom: var(--space-8); +} +.page-subtitle { + max-width: 720px; + margin: 0; + color: var(--text-secondary); + font-size: 1rem; +} +.eyebrow { + margin-bottom: 4px; + color: var(--accent); + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.095em; + text-transform: uppercase; } -input, -select, -textarea { - font: inherit; - padding: 0.5rem 0.6rem; +.button-primary, +.button-secondary, +.button-ghost, +button:not(.unstyled) { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + min-height: 40px; + padding: 9px 14px; + border: 1px solid transparent; + border-radius: var(--radius-sm); + font-weight: 700; + transition: + transform var(--transition), + background var(--transition), + border-color var(--transition); +} +.button-primary, +button:not(.secondary):not(.button-ghost):not(.unstyled) { + color: #fff; + background: var(--accent); + border-color: var(--accent); +} +.button-primary:hover, +button:not(.secondary):not(.button-ghost):not(.unstyled):hover { + background: var(--accent-hover); + border-color: var(--accent-hover); + transform: translateY(-1px); +} +.button-secondary, +button.secondary { + color: var(--accent); + background: var(--surface-solid); + border-color: var(--border-strong); +} +.button-ghost { + color: var(--text-secondary); + background: transparent; +} +.button-ghost:hover, +.button-secondary:hover, +button.secondary:hover { + background: var(--bg-subtle); +} + +.metrics-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--space-4); + margin-bottom: var(--space-6); +} +.metric-card, +.panel, +.form-section, +.detail-card { border: 1px solid var(--border); - border-radius: 4px; - background: var(--bg); + border-radius: var(--radius-lg); + background: var(--surface); + box-shadow: var(--shadow-sm); +} +.metric-card { + display: grid; + gap: 4px; + min-height: 145px; + padding: var(--space-6); +} +.metric-card > span { + color: var(--text-secondary); + font-size: 0.83rem; + font-weight: 700; +} +.metric-card strong { + font-size: 2rem; + letter-spacing: -0.05em; + line-height: 1; +} +.metric-card small { + margin-top: auto; +} +.accent-metric { + border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); + background: color-mix(in srgb, var(--accent-soft) 62%, var(--surface)); +} +.metric-card .metric-time { + font-size: 1.25rem; + letter-spacing: -0.03em; +} +.dashboard-grid { + display: grid; + grid-template-columns: 1.3fr 1fr; + gap: var(--space-6); +} +.panel { + padding: var(--space-6); +} +.panel-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-3); + margin-bottom: var(--space-4); +} +.panel-header h2 { + margin: 0; +} +.compact-list { + display: grid; +} +.compact-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + padding: 13px 0; + border-top: 1px solid var(--border); color: var(--text); } - -textarea { - min-height: 7rem; - resize: vertical; +.compact-row:hover { + color: var(--accent); +} +.compact-row span:first-child { + display: grid; + min-width: 0; +} +.compact-row strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.compact-row small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.inline-empty { + display: grid; + place-items: start; + gap: 10px; + min-height: 140px; + padding: 22px 0 6px; + color: var(--text-secondary); +} +.inline-empty p { + margin: 0; + font-weight: 650; +} +.status-pill, +.tag, +.filter-chip { + display: inline-flex; + align-items: center; + width: fit-content; + padding: 4px 8px; + border-radius: 999px; + color: var(--text-secondary); + background: var(--bg-subtle); + font-size: 0.75rem; + font-weight: 700; + text-transform: capitalize; } -nav { +/* Opportunity workspace */ +.header-actions { display: flex; flex-wrap: wrap; - gap: 0.75rem; - margin: 0 0 1.5rem; + justify-content: flex-end; + gap: 9px; } - -button { - font: inherit; - padding: 0.5rem 0.9rem; - border: 1px solid var(--accent); - border-radius: 4px; - background: var(--accent); - color: #fff; - cursor: pointer; +.opportunity-tabs { + display: flex; + gap: 4px; width: fit-content; + margin: -8px 0 var(--space-4); + padding: 4px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface); } - -button:disabled { - opacity: 0.6; - cursor: not-allowed; +.opportunity-tabs button, +.opportunity-tabs button:not(.secondary):not(.button-ghost):not(.unstyled) { + min-height: 32px; + padding: 5px 10px; + border: 0; + border-radius: 7px; + color: var(--text-secondary); + background: transparent; + font-size: 0.82rem; + font-weight: 750; } - -button.secondary { +.opportunity-tabs button.active { + color: var(--accent); + background: var(--accent-soft); +} +.refresh-strip { + display: flex; + align-items: center; + gap: 10px; + min-height: 60px; + margin-bottom: var(--space-5); + padding: 10px 15px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + color: var(--text-secondary); + background: var(--surface); +} +.refresh-strip span:nth-child(2) { + display: grid; + min-width: 0; +} +.refresh-strip small { + font-size: 0.78rem; +} +.refresh-dot { + flex: 0 0 auto; + width: 9px; + height: 9px; + border-radius: 999px; + background: var(--text-muted); +} +.refresh-dot.ready { + background: var(--positive); + box-shadow: 0 0 0 4px var(--positive-soft); +} +.refresh-dot.refreshing { + background: var(--warning); + animation: pulse 1s ease-in-out infinite; +} +.refresh-dot.failed { + background: var(--danger); +} +.enabled-sources { + margin-left: auto; + color: var(--text-muted); + font-size: 0.8rem; + text-align: right; +} +.search-bar { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + padding: 6px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + background: var(--surface-solid); + box-shadow: var(--shadow-sm); +} +.search-bar input { + min-width: 0; + border: 0; background: transparent; + box-shadow: none; + font-size: 1rem; +} +.search-bar input:focus { + box-shadow: none; +} +.quick-filters, +.active-filters { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 7px; + margin-bottom: var(--space-4); +} +.quick-filters button, +.quick-filters button:not(.secondary):not(.button-ghost):not(.unstyled) { + min-height: 32px; + padding: 5px 10px; + border: 1px solid var(--border); + border-radius: 999px; + color: var(--text-secondary); + background: var(--surface); + font-size: 0.83rem; + font-weight: 700; +} +.quick-filters button:hover, +.quick-filters button.active { + border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); color: var(--accent); + background: var(--accent-soft); +} +.advanced-filters { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 10px; + margin: 0 0 var(--space-4); + padding: var(--space-4); + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface); +} +.advanced-filters label { + display: grid; + gap: 5px; +} +.advanced-filters select { + min-height: 37px; +} +.advanced-filters .check-label { + display: flex; + align-items: end; + gap: 7px; + padding-bottom: 10px; +} +.advanced-filters .check-label input { + width: auto; + min-height: 0; +} +.active-filters { + font-size: 0.8rem; + color: var(--text-muted); +} +.filter-chip { + border: 1px solid color-mix(in srgb, var(--accent) 20%, var(--border)); + color: var(--accent); + background: var(--accent-soft); +} +.results-heading { + display: flex; + align-items: center; + justify-content: space-between; + margin: var(--space-6) 0 var(--space-4); + color: var(--text-secondary); + font-size: 0.85rem; + font-weight: 700; +} +.state-card { + display: grid; + place-items: center; + min-height: 260px; + padding: 32px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + text-align: center; + background: var(--surface); +} +.state-card h2 { + margin-bottom: 6px; +} +.state-card p { + max-width: 440px; + color: var(--text-secondary); +} +.onboarding-state { + place-items: center; + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--accent-soft) 75%, var(--surface)), + var(--surface) + ); +} +.onboarding-state small { + margin-top: 13px; +} +.opportunity-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} +.opportunity-card { + display: flex; + flex-direction: column; + min-width: 0; + padding: 19px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface); + box-shadow: var(--shadow-sm); + transition: + border-color var(--transition), + box-shadow var(--transition), + transform var(--transition); +} +.opportunity-card:hover { + border-color: color-mix(in srgb, var(--accent) 38%, var(--border)); + box-shadow: var(--shadow-md); + transform: translateY(-2px); +} +.card-topline { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 24px; +} +.deadline { + color: var(--warning); + font-size: 0.75rem; + font-weight: 750; + white-space: nowrap; +} +.opportunity-card h2 { + margin: 13px 0 2px; + overflow: hidden; + color: var(--text); + font-size: 1.08rem; + text-overflow: ellipsis; + white-space: nowrap; +} +.opportunity-card a:hover h2 { + color: var(--accent); +} +.company-line { + margin-bottom: 11px; + color: var(--text-secondary); + font-weight: 650; +} +.metadata { + display: flex; + flex-wrap: wrap; + gap: 5px 12px; + color: var(--text-muted); + font-size: 0.78rem; +} +.description-preview { + display: -webkit-box; + overflow: hidden; + margin: 14px 0 17px; + color: var(--text-secondary); + font-size: 0.88rem; + line-height: 1.52; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} +.opportunity-card footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-top: auto; + padding-top: 13px; + border-top: 1px solid var(--border); +} +.card-actions { + display: flex; + align-items: center; + gap: 3px; +} +.card-actions button, +.card-actions a { + min-height: 31px; + padding: 4px 7px; + border-radius: 6px; + font-size: 0.78rem; + font-weight: 750; +} +.saved-action { + border: 0; + color: var(--positive); + background: var(--positive-soft); +} +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 13px; + margin-top: var(--space-6); + color: var(--text-secondary); + font-size: 0.85rem; +} +.manual-results ul { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + padding: 0; + list-style: none; +} +.manual-results li { + padding: 16px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface); +} +.manual-results h2 { + margin-bottom: 4px; +} +.manual-results p { + color: var(--text-secondary); } -:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; +/* Application workspace */ +.application-summary { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 10px; + margin-bottom: var(--space-5); +} +.application-summary button, +.application-summary button:not(.secondary):not(.button-ghost):not(.unstyled) { + display: flex; + flex-direction: column; + align-items: flex-start; + min-height: 92px; + padding: 14px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + color: var(--text-secondary); + background: var(--surface); + text-align: left; +} +.application-summary button span { + text-transform: capitalize; + font-size: 0.78rem; + font-weight: 700; +} +.application-summary button strong { + margin-top: auto; + color: var(--text); + font-size: 1.45rem; + letter-spacing: -0.04em; +} +.application-summary button.active { + border-color: color-mix(in srgb, var(--accent) 42%, var(--border)); + background: var(--accent-soft); +} +.application-summary button.active strong { + color: var(--accent); +} +.workspace-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 15px; + margin-bottom: var(--space-3); + color: var(--text-secondary); + font-size: 0.85rem; + font-weight: 700; +} +.workspace-toolbar label { + display: flex; + align-items: center; + gap: 8px; +} +.workspace-toolbar select { + min-height: 36px; + width: auto; +} +.applications-list { + display: grid; + gap: 10px; +} +.application-row { + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(155px, 0.7fr) auto; + align-items: center; + gap: 22px; + padding: 18px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface); + box-shadow: var(--shadow-sm); +} +.application-row h2 { + margin: 0 0 2px; + color: var(--text); + font-size: 1rem; +} +.application-row h2:hover { + color: var(--accent); +} +.application-row p { + margin-bottom: 9px; + color: var(--text-secondary); + font-size: 0.88rem; +} +.application-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + color: var(--text-muted); + font-size: 0.78rem; +} +.status-preparing { + color: var(--info); + background: color-mix(in srgb, var(--info) 13%, var(--surface)); +} +.status-active { + color: var(--warning); + background: var(--warning-soft); +} +.status-interview { + color: var(--accent); + background: var(--accent-soft); +} +.status-closed { + color: var(--text-muted); +} +.next-action { + display: grid; + gap: 3px; + color: var(--text-secondary); + font-size: 0.78rem; +} +.next-action strong { + color: var(--text); + font-size: 0.88rem; } +.application-detail-page, +.opportunity-detail-page, +.manual-entry-page { + max-width: 1180px; +} +.application-detail-page > h2, +.opportunity-detail-page > h2, +.manual-entry-page > h2 { + margin-bottom: 3px; + font-size: 2rem; + letter-spacing: -0.045em; +} +.application-detail-page > form, +.application-detail-page > section, +.opportunity-detail-page > section, +.manual-entry-page > form { + margin: var(--space-5) 0; + padding: var(--space-6); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface); +} +.application-detail-page > form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + max-width: none; +} +.application-detail-page > form > div:nth-child(3), +.application-detail-page > form > div:nth-child(4), +.application-detail-page > form > button { + grid-column: 1 / -1; +} +.application-detail-page > section > form { + grid-template-columns: repeat(2, minmax(0, 1fr)); + max-width: none; +} +.application-detail-page > section > form > div:nth-child(4), +.application-detail-page > section > form > div:nth-child(5), +.application-detail-page > section > form > div:nth-child(8), +.application-detail-page > section > form > button { + grid-column: 1 / -1; +} +.manual-entry-page > form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + max-width: none; +} +.manual-entry-page > form > div:first-child, +.manual-entry-page > form > div:nth-child(8), +.manual-entry-page > form > div:nth-child(11), +.manual-entry-page > form > button { + grid-column: 1 / -1; +} +.opportunity-detail-page { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(260px, 0.38fr); + gap: var(--space-6); +} +.opportunity-detail-page > h2, +.opportunity-detail-page > p:first-of-type { + grid-column: 1 / -1; +} +.opportunity-detail-page > section { + grid-column: 1; + margin: 0; +} +.opportunity-detail-page > button, +.opportunity-detail-page > a, +.opportunity-detail-page > p:not(:first-of-type) { + grid-column: 2; +} +@keyframes pulse { + 50% { + opacity: 0.45; + transform: scale(0.72); + } +} + +/* Reusable form system */ +form { + display: grid; + gap: var(--space-5); +} +form > div { + display: grid; + gap: 6px; +} +label { + color: var(--text-secondary); + font-size: 0.83rem; + font-weight: 750; +} +input, +select, +textarea { + width: 100%; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + color: var(--text); + background: var(--surface-solid); + box-shadow: inset 0 1px 1px rgba(10, 24, 42, 0.02); + transition: + border-color var(--transition), + box-shadow var(--transition); +} +input, +select { + min-height: 42px; + padding: 9px 11px; +} +textarea { + min-height: 130px; + padding: 11px; + resize: vertical; +} +input:focus, +select:focus, +textarea:focus { + border-color: var(--accent); + outline: none; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent); +} [role='alert'] { color: var(--danger); font-size: 0.9rem; } + +/* Existing profile and form routes inherit the shell instead of a narrow column. */ +.app-content > section:not(.page) { + max-width: 1180px; +} +.app-content > section:not(.page) > h2 { + font-size: 1.75rem; + letter-spacing: -0.04em; +} +.app-content > section:not(.page) > form { + max-width: 860px; + padding: var(--space-6); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface); +} +.app-content > section:not(.page) > ul { + display: grid; + gap: 10px; + padding: 0; + list-style: none; +} +.app-content > section:not(.page) > ul > li { + padding: var(--space-4); + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface); +} +.app-content nav[aria-label='Profile sections'] { + display: flex; + flex-wrap: wrap; + gap: 7px; + margin: 0 0 var(--space-6); + padding: 7px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface); +} +.app-content nav[aria-label='Profile sections'] a { + padding: 7px 10px; + border-radius: 7px; + color: var(--text-secondary); + font-size: 0.84rem; + font-weight: 700; +} +.app-content nav[aria-label='Profile sections'] a.active { + color: var(--accent); + background: var(--accent-soft); +} +.profile-overview { + max-width: 880px; +} +.profile-heading { + margin: 0 0 var(--space-6); +} +.profile-heading h1 { + margin-bottom: 7px; +} +.profile-heading > p:last-child { + color: var(--text-secondary); +} +.profile-section-card { + margin-bottom: var(--space-4); + padding: var(--space-6); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface); +} +.profile-section-card h2 { + margin-bottom: 16px; +} +.profile-checks { + display: grid; +} +.profile-check { + display: grid; + grid-template-columns: 30px minmax(0, 1fr) auto; + align-items: center; + gap: 11px; + padding: 12px 0; + border-top: 1px solid var(--border); +} +.profile-check div { + display: grid; +} +.profile-check small { + text-transform: capitalize; +} +.profile-check a { + font-size: 0.82rem; + font-weight: 750; +} +.check-complete, +.check-pending { + display: grid; + place-items: center; + width: 24px; + height: 24px; + border-radius: 999px; + color: var(--positive); + background: var(--positive-soft); + font-size: 0.8rem; + font-weight: 800; +} +.check-pending { + color: var(--accent); + background: var(--accent-soft); +} +.profile-later { + color: var(--text-secondary); + background: color-mix(in srgb, var(--surface) 85%, var(--bg-subtle)); +} +.profile-later h2 { + color: var(--text); +} + +@media (max-width: 1040px) { + .metrics-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .dashboard-grid { + grid-template-columns: 1fr; + } + .advanced-filters { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .application-summary { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} +@media (max-width: 700px) { + .app-shell { + display: block; + padding-bottom: 68px; + } + .sidebar { + position: fixed; + z-index: 5; + bottom: 0; + top: auto; + width: 100%; + height: 64px; + padding: 7px 8px; + border-top: 1px solid rgba(255, 255, 255, 0.12); + } + .brand, + .sidebar-footer { + display: none; + } + .primary-nav { + grid-template-columns: repeat(4, 1fr); + gap: 2px; + width: 100%; + } + .primary-nav a { + overflow: hidden; + padding: 10px 3px; + font-size: 0.72rem; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; + } + .app-content { + padding: 25px 16px 40px; + } + .page-header { + display: grid; + margin-bottom: var(--space-6); + } + .page-header .button-primary { + width: 100%; + } + .metrics-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + } + .metric-card { + min-height: 116px; + padding: 14px; + } + .metric-card strong { + font-size: 1.55rem; + } + .panel { + padding: 17px; + } + .panel-header { + align-items: flex-start; + } + .compact-row { + align-items: flex-start; + } + .app-content > section:not(.page) > form { + padding: 16px; + } + .refresh-strip { + align-items: flex-start; + } + .enabled-sources { + display: none; + } + .advanced-filters { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .opportunity-grid { + grid-template-columns: 1fr; + } + .opportunity-card footer { + align-items: flex-start; + flex-direction: column; + } + .header-actions { + justify-content: stretch; + } + .header-actions > * { + flex: 1; + text-align: center; + } + .application-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .application-row { + grid-template-columns: 1fr; + gap: 12px; + } + .application-row .button-secondary { + width: 100%; + } + .application-detail-page > form, + .application-detail-page > section > form { + grid-template-columns: 1fr; + } + .application-detail-page > form > *, + .application-detail-page > section > form > * { + grid-column: auto !important; + } + .manual-entry-page > form { + grid-template-columns: 1fr; + } + .manual-entry-page > form > * { + grid-column: auto !important; + } + .opportunity-detail-page { + display: block; + } + .opportunity-detail-page > section { + margin: var(--space-5) 0; + } +} diff --git a/app/src/lib/personalRuntime.ts b/app/src/lib/personalRuntime.ts new file mode 100644 index 0000000..30be57d --- /dev/null +++ b/app/src/lib/personalRuntime.ts @@ -0,0 +1,36 @@ +export interface RefreshResult { + checked: number + created: number + updated: number + unchanged: number + errors: number +} + +export interface RefreshStatus { + state: 'idle' | 'refreshing' | 'ready' | 'failed' + lastCompletedAt: string | null + result: RefreshResult | null + error: string | null +} + +const runtimeUrl = import.meta.env.VITE_PERSONAL_RUNTIME_URL + +async function request( + path: string, + init?: RequestInit, +): Promise { + if (!runtimeUrl) return null + try { + const response = await fetch(`${runtimeUrl}${path}`, init) + if (!response.ok) return null + return (await response.json()) as RefreshStatus + } catch { + return null + } +} + +export const personalRuntime = { + status: () => request('/status'), + refresh: () => request('/refresh', { method: 'POST' }), + enabled: Boolean(runtimeUrl), +} diff --git a/app/src/pages/AppLayout.tsx b/app/src/pages/AppLayout.tsx index 2f78c5b..bbb379d 100644 --- a/app/src/pages/AppLayout.tsx +++ b/app/src/pages/AppLayout.tsx @@ -3,7 +3,7 @@ import { useState } from 'react' import { useAuth } from '../contexts/AuthContext' export function AppLayout() { - const { signOut } = useAuth() + const { signOut, personalMode } = useAuth() const [error, setError] = useState(null) async function handleSignOut() { @@ -13,24 +13,41 @@ export function AppLayout() { } return ( -
-
-

CareerOS

- -
- {error &&

{error}

} - - -
+ C + CareerOS + + +
+ + {personalMode ? 'Personal workspace' : 'CareerOS'} + + {!personalMode && ( + + )} +
+ +
+ {error &&

{error}

} + +
+ ) } diff --git a/app/src/pages/ApplicationDetailPage.tsx b/app/src/pages/ApplicationDetailPage.tsx index 1fb1ff6..b018980 100644 --- a/app/src/pages/ApplicationDetailPage.tsx +++ b/app/src/pages/ApplicationDetailPage.tsx @@ -228,7 +228,7 @@ export function ApplicationDetailPage() { ) return ( -
+

{pinned?.title ?? 'Application'}

{pinned &&

{pinned.organization}

} {message &&

{message}

} diff --git a/app/src/pages/ApplicationsListPage.tsx b/app/src/pages/ApplicationsListPage.tsx index d11dc9d..eea8009 100644 --- a/app/src/pages/ApplicationsListPage.tsx +++ b/app/src/pages/ApplicationsListPage.tsx @@ -5,7 +5,6 @@ import { applicationRepository } from '../lib/applicationRepository' import { opportunityRepository } from '../lib/opportunityRepository' import { applicationStatusLabels, - applicationStatusOptions, type Application, } from '../lib/opportunityTypes' @@ -14,41 +13,48 @@ type VersionInfo = { organization: string application_url: string | null } - +type Filter = 'all' | 'preparing' | 'active' | 'interview' | 'closed' function formatDate(value: string | null) { - if (!value) return null - return new Intl.DateTimeFormat('en', { - year: 'numeric', - month: 'short', - day: 'numeric', - }).format(new Date(value)) + return value + ? new Intl.DateTimeFormat('en', { month: 'short', day: 'numeric' }).format( + new Date(value), + ) + : null +} +function group(status: Application['status']): Exclude { + if (status === 'preparing') return 'preparing' + if (status === 'interview_scheduled' || status === 'interview_complete') + return 'interview' + if (['rejected', 'withdrawn', 'closed', 'accepted'].includes(status)) + return 'closed' + return 'active' } - -type Sort = 'next_action' | 'recent_update' export function ApplicationsListPage() { const { session } = useAuth() const userId = session?.user.id - const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading') + const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading') const [applications, setApplications] = useState([]) const [versions, setVersions] = useState>(new Map()) + const [filter, setFilter] = useState('all') const [statusFilter, setStatusFilter] = useState('') - const [sort, setSort] = useState('recent_update') - + const [sort, setSort] = useState<'next_action' | 'recent_update'>( + 'next_action', + ) const load = useCallback(async () => { if (!userId) return - setStatus('loading') + setState('loading') const result = await applicationRepository.list(userId) if (result.error) { - setStatus('error') + setState('error') return } setApplications(result.data) - const versionIds = result.data - .map((app) => app.opportunity_version_id) + const ids = result.data + .map((item) => item.opportunity_version_id) .filter((id): id is string => Boolean(id)) - const versionsResult = await opportunityRepository.getVersions(versionIds) - if (!versionsResult.error) { + const versionsResult = await opportunityRepository.getVersions(ids) + if (!versionsResult.error) setVersions( new Map( (versionsResult.data ?? []).map((row) => [ @@ -61,17 +67,14 @@ export function ApplicationsListPage() { ]), ), ) - } - setStatus('ready') + setState('ready') }, [userId]) - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect -- asynchronous initial load updates state after the repository resolves + // eslint-disable-next-line react-hooks/set-state-in-effect -- asynchronous repository load updates state after resolution void load() }, [load]) - - function titleFor(app: Application): VersionInfo { - if (app.opportunity_version_id) { + function info(app: Application): VersionInfo { + if (app.opportunity_version_id) return ( versions.get(app.opportunity_version_id) ?? { title: 'Opportunity', @@ -79,123 +82,179 @@ export function ApplicationsListPage() { application_url: null, } ) - } const snapshot = app.private_opportunity_snapshot ?? {} return { title: (snapshot.title as string) ?? 'Manual opportunity', organization: (snapshot.organization_name as string) ?? '', - application_url: (snapshot.application_url as string | null) ?? null, + application_url: (snapshot.application_url as string) ?? null, } } - - const visible = useMemo(() => { - let list = applications - if (statusFilter) list = list.filter((app) => app.status === statusFilter) - list = [...list] - if (sort === 'next_action') { - list.sort((a, b) => { - if (!a.next_action_due_at) return 1 - if (!b.next_action_due_at) return -1 - return a.next_action_due_at.localeCompare(b.next_action_due_at) - }) - } else { - list.sort((a, b) => - b.status_updated_at.localeCompare(a.status_updated_at), - ) - } - return list - }, [applications, statusFilter, sort]) - - if (status === 'loading') - return ( -
-

Applications

-

Loading…

-
- ) - if (status === 'error') - return ( -
-

Applications

-

Applications could not be loaded.

- -
- ) - + const counts = { + all: applications.length, + preparing: applications.filter((app) => group(app.status) === 'preparing') + .length, + active: applications.filter((app) => group(app.status) === 'active').length, + interview: applications.filter((app) => group(app.status) === 'interview') + .length, + closed: applications.filter((app) => group(app.status) === 'closed').length, + } + const visible = useMemo( + () => + applications + .filter( + (app) => + (filter === 'all' || group(app.status) === filter) && + (!statusFilter || app.status === statusFilter), + ) + .sort((a, b) => + sort === 'next_action' + ? (a.next_action_due_at ?? '9999').localeCompare( + b.next_action_due_at ?? '9999', + ) + : b.status_updated_at.localeCompare(a.status_updated_at), + ), + [applications, filter, statusFilter, sort], + ) return ( -
-

Applications

- - - - - - {visible.length === 0 ? ( -

No applications yet. Start one from an opportunity's detail page.

- ) : ( -
    + +
    + + {visible.length} application{visible.length === 1 ? '' : 's'} + + + +
    + {state === 'loading' && ( +
    +

    Loading applications…

    + Loading… +
    + )} + {state === 'error' && ( +
    +

    Applications could not be loaded

    + +
    + )} + {state === 'ready' && visible.length === 0 && ( +
    +

    + {applications.length + ? 'No applications in this view' + : 'No applications yet'} +

    +

    + {applications.length + ? 'Choose another status group to see those applications.' + : 'Start an application from an opportunity when you are ready to take action.'} +

    + + Explore opportunities + +
    + )} + {state === 'ready' && visible.length > 0 && ( +
    {visible.map((app) => { - const info = titleFor(app) + const item = info(app) return ( -
  • - - {info.title} - {' '} - at {info.organization} -
    - Status: {applicationStatusLabels[app.status]} - {app.applied_at && ( - <> - {' '} - · Applied {formatDate(app.applied_at)} - - )} - {app.next_action && ( - <> -
    - Next: {app.next_action} +
    +
    + +

    {item.title}

    + +

    {item.organization || 'Personal opportunity'}

    + at {item.organization} +
    + + {applicationStatusLabels[app.status]} + + {app.applied_at && ( + Applied {formatDate(app.applied_at)} + )} {app.next_action_due_at && ( - (due {formatDate(app.next_action_due_at)}) + Due {formatDate(app.next_action_due_at)} )} - - )} - {info.application_url && ( - <> - {' '} - ·{' '} - - Open application page - - - )} -
  • +
    + +
    + Next action + {app.next_action ?? 'No follow-up planned'} +
    + + Open + + ) })} -
+ )}
) diff --git a/app/src/pages/DashboardPage.tsx b/app/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..e8bfe98 --- /dev/null +++ b/app/src/pages/DashboardPage.tsx @@ -0,0 +1,202 @@ +import { Link } from 'react-router' +import { useCallback, useEffect, useState } from 'react' +import { useAuth } from '../contexts/AuthContext' +import { applicationRepository } from '../lib/applicationRepository' +import { opportunityRepository } from '../lib/opportunityRepository' +import { personalRuntime, type RefreshStatus } from '../lib/personalRuntime' +import type { Application, OpportunitySearchRow } from '../lib/opportunityTypes' + +function relativeTime(value: string | null) { + if (!value) return 'Not refreshed yet' + const minutes = Math.max( + 0, + Math.round((Date.now() - new Date(value).getTime()) / 60000), + ) + return minutes < 1 + ? 'Just now' + : minutes === 1 + ? '1 minute ago' + : `${minutes} minutes ago` +} + +export function DashboardPage() { + const { session } = useAuth() + const userId = session?.user.id + const [opportunities, setOpportunities] = useState([]) + const [total, setTotal] = useState(0) + const [applications, setApplications] = useState([]) + const [refresh, setRefresh] = useState(null) + + const load = useCallback(async () => { + if (!userId) return + const [opportunityResult, applicationResult, refreshResult] = + await Promise.all([ + opportunityRepository.search({}, 'discovered_desc', 0, 5), + applicationRepository.list(userId), + personalRuntime.status(), + ]) + if (!opportunityResult.error) { + setOpportunities(opportunityResult.data) + setTotal(opportunityResult.count ?? opportunityResult.data.length) + } + if (!applicationResult.error) setApplications(applicationResult.data) + setRefresh(refreshResult) + }, [userId]) + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- asynchronous repository load updates state after resolution + void load() + }, [load]) + useEffect(() => { + if (refresh?.state !== 'refreshing') return + const timer = window.setInterval( + () => void personalRuntime.status().then(setRefresh), + 1600, + ) + return () => window.clearInterval(timer) + }, [refresh?.state]) + + const saved = applications.filter( + (application) => application.status !== 'closed', + ).length + const interviews = applications.filter( + (application) => application.status === 'interview_scheduled', + ).length + const needsAction = applications + .filter((application) => application.next_action) + .slice(0, 4) + return ( +
+
+
+

Personal career workspace

+

Good to see your progress.

+

+ A calm view of your opportunity pipeline and next steps. +

+
+ +
+ +
+
+ Discovered + {total} + opportunities available +
+
+ In progress + {saved} + applications being tracked +
+
+ Interviews + {interviews} + scheduled conversations +
+
+ Last refresh + + {relativeTime(refresh?.lastCompletedAt ?? null)} + + + {refresh?.result + ? `${refresh.result.checked} opportunities checked` + : 'Ready when you are'} + +
+
+ +
+
+
+
+

Freshly discovered

+

Recent opportunities

+
+ View all +
+ {opportunities.length === 0 ? ( +
+

No opportunities imported yet.

+ +
+ ) : ( +
+ {opportunities.map((opportunity) => ( + + + {opportunity.title} + + {opportunity.organization} ·{' '} + {opportunity.location_text ?? 'Location not listed'} + + + {opportunity.remote_mode} + + ))} +
+ )} +
+
+
+
+

Keep momentum

+

Next actions

+
+ Open applications +
+ {needsAction.length === 0 ? ( +
+

No next actions yet.

+ + Add one from an application to keep your follow-up plan visible + here. + +
+ ) : ( +
+ {needsAction.map((application) => ( + + + {application.next_action} + + {application.next_action_due_at + ? `Due ${new Date(application.next_action_due_at).toLocaleDateString()}` + : 'No due date'} + + + + {application.status.replaceAll('_', ' ')} + + + ))} +
+ )} +
+
+
+ ) +} diff --git a/app/src/pages/ManualOpportunityFormPage.tsx b/app/src/pages/ManualOpportunityFormPage.tsx index 70ee689..1ce7fd8 100644 --- a/app/src/pages/ManualOpportunityFormPage.tsx +++ b/app/src/pages/ManualOpportunityFormPage.tsx @@ -83,7 +83,7 @@ export function ManualOpportunityFormPage() { } return ( -
+

Add an opportunity manually

Use this for a posting from a source CareerOS doesn't automatically diff --git a/app/src/pages/OpportunityDetailPage.tsx b/app/src/pages/OpportunityDetailPage.tsx index eb476fa..4f5dab6 100644 --- a/app/src/pages/OpportunityDetailPage.tsx +++ b/app/src/pages/OpportunityDetailPage.tsx @@ -169,7 +169,7 @@ export function OpportunityDetailPage() { ) return ( -

+

{row.title}

{row.organization} · {row.source_display_name} diff --git a/app/src/pages/OpportunityListPage.tsx b/app/src/pages/OpportunityListPage.tsx index 6473e21..2cd9fa1 100644 --- a/app/src/pages/OpportunityListPage.tsx +++ b/app/src/pages/OpportunityListPage.tsx @@ -1,14 +1,15 @@ import { Link } from 'react-router' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useAuth } from '../contexts/AuthContext' +import { applicationRepository } from '../lib/applicationRepository' import { - PAGE_SIZE, opportunityRepository, + PAGE_SIZE, type OpportunityFilters, type OpportunitySort, } from '../lib/opportunityRepository' +import { personalRuntime, type RefreshStatus } from '../lib/personalRuntime' import { privateOpportunityRepository } from '../lib/privateOpportunityRepository' -import { applicationRepository } from '../lib/applicationRepository' import { employmentTypeLabels, employmentTypeOptions, @@ -22,27 +23,32 @@ import { } from '../lib/opportunityTypes' import { errorMessage, safeError } from '../lib/profileTypes' -type Tab = 'discovered' | 'manual' -type Status = 'loading' | 'ready' | 'error' type SourceOption = { source_key: string; display_name: string } - -const locationPresets = ['Bremen', 'Hamburg'] +type Status = 'loading' | 'ready' | 'error' function formatDate(value: string | null) { if (!value) return null return new Intl.DateTimeFormat('en', { - year: 'numeric', month: 'short', day: 'numeric', + year: 'numeric', }).format(new Date(value)) } +function preview(value: string) { + return value.length > 185 ? `${value.slice(0, 185).trim()}…` : value +} +function relative(value: string | null) { + if (!value) return 'Not refreshed yet' + const minutes = Math.max( + 0, + Math.round((Date.now() - new Date(value).getTime()) / 60000), + ) + return minutes < 1 ? 'Just now' : `${minutes} min ago` +} export function OpportunityListPage() { const { session } = useAuth() const userId = session?.user.id - const [tab, setTab] = useState('discovered') - - // Discovered (shared) state ------------------------------------------- const [search, setSearch] = useState('') const [kind, setKind] = useState('') const [employmentType, setEmploymentType] = useState('') @@ -51,63 +57,44 @@ export function OpportunityListPage() { const [lifecycleStatus, setLifecycleStatus] = useState('') const [locationText, setLocationText] = useState('') const [sort, setSort] = useState('discovered_desc') - const [showHidden, setShowHidden] = useState(false) const [savedOnly, setSavedOnly] = useState(false) const [appliedOnly, setAppliedOnly] = useState(false) + const [showHidden, setShowHidden] = useState(false) + const [advanced, setAdvanced] = useState(false) const [page, setPage] = useState(0) const [rows, setRows] = useState([]) const [total, setTotal] = useState(0) const [stateMap, setStateMap] = useState< Map >(new Map()) - const [appliedOpportunityIds, setAppliedOpportunityIds] = useState< - Set - >(new Set()) + const [appliedIds, setAppliedIds] = useState>(new Set()) const [sources, setSources] = useState([]) - const [discoveredStatus, setDiscoveredStatus] = useState('loading') - const [pendingSave, setPendingSave] = useState(null) - - // Manual state ------------------------------------------------------ - const [showDismissed, setShowDismissed] = useState(false) - const [manualRows, setManualRows] = useState([]) - const [manualStatus, setManualStatus] = useState('loading') - + const [status, setStatus] = useState('loading') + const [refresh, setRefresh] = useState(null) + const [pendingId, setPendingId] = useState(null) const [message, setMessage] = useState(null) + const [manual, setManual] = useState([]) + const [tab, setTab] = useState<'discovered' | 'manual'>('discovered') - const loadDiscovered = useCallback(async () => { + const load = useCallback(async () => { if (!userId) return - setDiscoveredStatus('loading') - - // State/applications sets are small (per-user) -- fetch them first so - // saved/hidden/applied filters can be pushed into the shared-opportunity - // query itself, applied before pagination and the exact count, rather - // than filtered out of an already-paginated page client-side. - const [stateResult, applicationsResult] = await Promise.all([ + setStatus('loading') + const [states, applications] = await Promise.all([ opportunityRepository.listState(userId), applicationRepository.list(userId), ]) - if (stateResult.error || applicationsResult.error) { - setDiscoveredStatus('error') + if (states.error || applications.error) { + setStatus('error') return } - const nextStateMap = new Map( - stateResult.data.map((row) => [row.opportunity_id, row]), + const nextStates = new Map( + states.data.map((row) => [row.opportunity_id, row]), ) - setStateMap(nextStateMap) - const appliedIds = new Set( - applicationsResult.data - .map((app) => app.shared_opportunity_id) + const nextApplied = new Set( + applications.data + .map((application) => application.shared_opportunity_id) .filter((id): id is string => Boolean(id)), ) - setAppliedOpportunityIds(appliedIds) - - const hiddenIds = [...nextStateMap.values()] - .filter((row) => row.hidden_at) - .map((row) => row.opportunity_id) - const savedIds = [...nextStateMap.values()] - .filter((row) => row.saved_at) - .map((row) => row.opportunity_id) - const filters: OpportunityFilters = { search, opportunityKind: kind || undefined, @@ -117,25 +104,29 @@ export function OpportunityListPage() { lifecycleStatus: lifecycleStatus || undefined, locationText: locationText || undefined, } + const savedIds = [...nextStates.values()] + .filter((row) => row.saved_at) + .map((row) => row.opportunity_id) + const hiddenIds = [...nextStates.values()] + .filter((row) => row.hidden_at) + .map((row) => row.opportunity_id) if (!showHidden) filters.excludeOpportunityIds = hiddenIds - if (savedOnly && appliedOnly) { + if (savedOnly && appliedOnly) filters.includeOpportunityIds = savedIds.filter((id) => - appliedIds.has(id), + nextApplied.has(id), ) - } else if (savedOnly) { - filters.includeOpportunityIds = savedIds - } else if (appliedOnly) { - filters.includeOpportunityIds = [...appliedIds] - } - - const searchResult = await opportunityRepository.search(filters, sort, page) - if (searchResult.error) { - setDiscoveredStatus('error') + else if (savedOnly) filters.includeOpportunityIds = savedIds + else if (appliedOnly) filters.includeOpportunityIds = [...nextApplied] + const result = await opportunityRepository.search(filters, sort, page) + if (result.error) { + setStatus('error') return } - setRows(searchResult.data) - setTotal(searchResult.count ?? searchResult.data.length) - setDiscoveredStatus('ready') + setRows(result.data) + setTotal(result.count ?? result.data.length) + setStateMap(nextStates) + setAppliedIds(nextApplied) + setStatus('ready') }, [ userId, search, @@ -146,102 +137,164 @@ export function OpportunityListPage() { lifecycleStatus, locationText, sort, - showHidden, savedOnly, appliedOnly, + showHidden, page, ]) - const loadManual = useCallback(async () => { + const loadMeta = useCallback(async () => { if (!userId) return - setManualStatus('loading') - const result = await privateOpportunityRepository.list( - userId, - showDismissed, - ) - if (result.error) { - setManualStatus('error') - return - } - setManualRows(result.data) - setManualStatus('ready') - }, [userId, showDismissed]) - + const [sourceResult, manualResult, refreshResult] = await Promise.all([ + opportunityRepository.listSources(), + privateOpportunityRepository.list(userId, false), + personalRuntime.status(), + ]) + if (!sourceResult.error) setSources(sourceResult.data) + if (!manualResult.error) setManual(manualResult.data) + setRefresh(refreshResult) + }, [userId]) useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect -- asynchronous initial load updates state after the repository resolves - void loadDiscovered() - }, [loadDiscovered]) - + // eslint-disable-next-line react-hooks/set-state-in-effect -- asynchronous repository load updates state after resolution + void load() + }, [load]) useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect -- asynchronous initial load updates state after the repository resolves - void loadManual() - }, [loadManual]) - + // eslint-disable-next-line react-hooks/set-state-in-effect -- asynchronous metadata load updates state after resolution + void loadMeta() + }, [loadMeta]) useEffect(() => { - let cancelled = false - void opportunityRepository.listSources().then((result) => { - if (!cancelled && !result.error) setSources(result.data) - }) - return () => { - cancelled = true - } - }, []) + if (refresh?.state !== 'refreshing') return + const timer = window.setInterval( + () => + void personalRuntime.status().then((result) => { + setRefresh(result) + if (result?.state === 'ready') { + void load() + void loadMeta() + } + }), + 1600, + ) + return () => window.clearInterval(timer) + }, [refresh?.state, load, loadMeta]) - function updateFilter(setter: (value: T) => void, value: T) { - setter(value) + function change(value: (next: string) => void, next: string) { + value(next) setPage(0) } - async function toggleSave(row: OpportunitySearchRow) { - if (!userId || pendingSave) return - setPendingSave(row.opportunity_id) - const isSaved = Boolean(stateMap.get(row.opportunity_id)?.saved_at) + if (!userId || pendingId) return + setPendingId(row.opportunity_id) const result = await opportunityRepository.setSaved( userId, row.opportunity_id, row.opportunity_version_id, - !isSaved, + !stateMap.get(row.opportunity_id)?.saved_at, ) if (result.error) setMessage(errorMessage(safeError(result.error))) - else await loadDiscovered() - setPendingSave(null) + else await load() + setPendingId(null) } - async function toggleHide(row: OpportunitySearchRow) { - if (!userId || pendingSave) return - setPendingSave(row.opportunity_id) - const isHidden = Boolean(stateMap.get(row.opportunity_id)?.hidden_at) + if (!userId || pendingId) return + setPendingId(row.opportunity_id) const result = await opportunityRepository.setHidden( userId, row.opportunity_id, - !isHidden, + !stateMap.get(row.opportunity_id)?.hidden_at, ) if (result.error) setMessage(errorMessage(safeError(result.error))) - else await loadDiscovered() - setPendingSave(null) + else await load() + setPendingId(null) } - - async function toggleDismiss(row: PrivateOpportunity) { - const result = await privateOpportunityRepository.setDismissed( - row.id, - !row.dismissed_at, - ) - if (result.error) setMessage(errorMessage(safeError(result.error))) - else await loadManual() + function clearAll() { + setSearch('') + setKind('') + setEmploymentType('') + setRemoteMode('') + setSourceKey('') + setLifecycleStatus('') + setLocationText('') + setSavedOnly(false) + setAppliedOnly(false) + setShowHidden(false) + setPage(0) } - + const active = useMemo( + () => + [ + { + label: + kind && + opportunityKindLabels[kind as keyof typeof opportunityKindLabels], + clear: () => setKind(''), + }, + { + label: + employmentType && + employmentTypeLabels[ + employmentType as keyof typeof employmentTypeLabels + ], + clear: () => setEmploymentType(''), + }, + { + label: + remoteMode && + remoteModeLabels[remoteMode as keyof typeof remoteModeLabels], + clear: () => setRemoteMode(''), + }, + { label: locationText, clear: () => setLocationText('') }, + { label: savedOnly ? 'Saved' : '', clear: () => setSavedOnly(false) }, + { + label: appliedOnly ? 'Applied' : '', + clear: () => setAppliedOnly(false), + }, + ].filter((item): item is { label: string; clear: () => void } => + Boolean(item.label), + ), + [kind, employmentType, remoteMode, locationText, savedOnly, appliedOnly], + ) + const noRun = refresh?.lastCompletedAt === null && total === 0 const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) return ( -

-

Opportunities

- {message &&

{message}

} -
+
+
+
+

Opportunity discovery

+

Opportunities

+

+ {total + ? `${total} opportunities ready to explore` + : 'Your current discovery workspace'} +

+
+
+ + + Add manually + +
+
+
- Add manually
- - {tab === 'discovered' && ( -
-
{ - event.preventDefault() +
+ + + + {refresh?.state === 'refreshing' + ? 'Refreshing your sources…' + : `Last refresh: ${relative(refresh?.lastCompletedAt ?? null)}`} + + {refresh?.result && ( + + {refresh.result.checked} checked · {refresh.result.created} new ·{' '} + {refresh.result.updated} updated · {refresh.result.unchanged}{' '} + unchanged + + )} + + + Enabled:{' '} + {sources.length + ? sources + .map((source) => + source.display_name.replace(' (Greenhouse)', ''), + ) + .join(', ') + : 'configured sources'} + +
+ {message &&

{message}

} +
+ change(setSearch, event.target.value)} + placeholder="Search roles, companies, skills, or locations" + /> + +
+
+ + + + {['Bremen', 'Hamburg'].map((city) => ( + + ))} + + + + + +
+ + {advanced && ( +
+ + + + + +
+ )} + {active.length > 0 && ( +
+ Active filters + {active.map((filter) => ( + + ))} + +
+ )} +
+ + {total} result{total === 1 ? '' : 's'} + + {manual.length > 0 && ( + + {manual.length} personal entry{manual.length === 1 ? '' : 'ies'} + + )} +
+ {status === 'loading' && ( +
+

Loading opportunities…

+ Loading… +
+ )} + {status === 'error' && ( +
+

Opportunities could not be loaded

+

Check that the local runtime is running, then try again.

+ +
+ )} + {status === 'ready' && + rows.length === 0 && + (noRun ? ( +
+

Start discovering

+

No opportunities imported yet

+

+ CareerOS can fetch roles from your configured sources and bring + them into one focused workspace. +

+ + + Enabled sources:{' '} + {sources.map((source) => source.display_name).join(', ') || + 'configured sources'} + +
+ ) : ( +
+

No opportunities match your filters

+

Try clearing a filter or expanding your search.

+ +
+ ))} + {status === 'ready' && rows.length > 0 && ( +
+ {rows.map((row) => { + const state = stateMap.get(row.opportunity_id) + const saved = Boolean(state?.saved_at) + const applied = appliedIds.has(row.opportunity_id) + return ( +
+
+ + {opportunityKindLabels[row.opportunity_kind]} + + {row.application_deadline && ( + + Deadline {formatDate(row.application_deadline)} - {row.application_deadline && ( - <> - {' '} - ·{' '} - - Deadline {formatDate(row.application_deadline)} - - - )} - {row.lifecycle_status !== 'active' && ( - <> - {' '} - ({row.lifecycle_status}) - - )} - {isSaved && · Saved} - {isApplied && · Applied} - {isHidden && · Hidden} -
+ )} +
+ +

{row.title}

+ +

{row.organization}

+
+ {row.location_text ?? 'Location not listed'} + {remoteModeLabels[row.remote_mode]} + {row.source_display_name} +
+

+ {preview(row.description)} +

+
+ + Discovered {formatDate(row.first_discovered_at)} + +
- - ) - })} - - )} -
- - - {' '} - Page {page + 1} of {totalPages}{' '} - - -
+ + {applied ? 'Application' : 'View'} + +
+
+
+ ) + })}
)} - + {total > PAGE_SIZE && ( + + )} + {total <= PAGE_SIZE && ( + + Page {page + 1} of {totalPages} + + )} {tab === 'manual' && ( -
- - {manualStatus === 'loading' &&

Loading…

} - {manualStatus === 'error' && ( -
-

Manual opportunities could not be loaded.

- -
- )} - {manualStatus === 'ready' && manualRows.length === 0 && ( -

- No manually added opportunities yet.{' '} - Add one. -

- )} - {manualStatus === 'ready' && manualRows.length > 0 && ( -
    - {manualRows.map((row) => ( -
  • - - {row.title} - {' '} - at {row.organization_name} -
    - Added by you ·{' '} - {opportunityKindLabels[row.opportunity_kind]} - {row.location_text && ( - <> - {' '} - · {row.location_text} - - )} - {row.dismissed_at && · Hidden} -
    - -
  • - ))} -
- )} +
+
    + {manual.map((entry) => ( +
  • + +

    {entry.title}

    + +

    + {entry.organization_name} · {entry.location_text} +

    + Added by you +
  • + ))} +
)}
diff --git a/app/src/pages/PrivateOpportunityDetailPage.tsx b/app/src/pages/PrivateOpportunityDetailPage.tsx index 98bea53..64193e5 100644 --- a/app/src/pages/PrivateOpportunityDetailPage.tsx +++ b/app/src/pages/PrivateOpportunityDetailPage.tsx @@ -203,14 +203,14 @@ export function PrivateOpportunityDetailPage() { if (status === 'loading') return ( -
+

Manual opportunity

Loading…

) if (status === 'missing') return ( -
+

Manual opportunity

This opportunity no longer exists.

Return to opportunities @@ -218,7 +218,7 @@ export function PrivateOpportunityDetailPage() { ) if (status === 'error' || !entry || !values) return ( -
+

Manual opportunity

This opportunity could not be loaded.