From 37892bcfac81951c46ca545197f58423706df91c Mon Sep 17 00:00:00 2001 From: Abdulrahman Date: Mon, 10 Aug 2026 15:36:45 +0300 Subject: [PATCH 1/4] feat: add local personal runtime --- .gitignore | 3 + README.md | 31 ++++- app/README.md | 14 ++- app/src/App.test.tsx | 40 +++++- app/src/App.tsx | 9 +- app/src/contexts/AuthContext.tsx | 64 +++++++--- app/src/lib/personalRuntime.ts | 36 ++++++ app/src/pages/AppLayout.tsx | 55 +++++--- app/src/pages/DashboardPage.tsx | 202 ++++++++++++++++++++++++++++++ docs/DEPLOYMENT_STRATEGY.md | 10 ++ docs/SECURITY_AND_PRIVACY.md | 9 ++ package-lock.json | 15 +++ package.json | 13 ++ scripts/ensure-personal-owner.mjs | 114 +++++++++++++++++ scripts/personal-runtime.mjs | 93 ++++++++++++++ scripts/personal.mjs | 95 ++++++++++++++ 16 files changed, 754 insertions(+), 49 deletions(-) create mode 100644 app/src/lib/personalRuntime.ts create mode 100644 app/src/pages/DashboardPage.tsx create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/ensure-personal-owner.mjs create mode 100644 scripts/personal-runtime.mjs create mode 100644 scripts/personal.mjs 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/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/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/docs/DEPLOYMENT_STRATEGY.md b/docs/DEPLOYMENT_STRATEGY.md index 839601f..d3a9c47 100644 --- a/docs/DEPLOYMENT_STRATEGY.md +++ b/docs/DEPLOYMENT_STRATEGY.md @@ -20,6 +20,16 @@ onboarded before a broader public launch. ### Local environment — implementation details (Phase 0) +**Personal Mode runtime (Phase 2A corrective release)**: ordinary local use is `npm run personal`, +not the multi-step developer path below. The launcher confirms Docker/Colima, starts Supabase if +needed, applies pending migrations with `supabase migration up --local`, creates or reuses the +local owner through the trusted Admin API, writes gitignored runtime env files, starts a +loopback-only refresh companion, runs the existing local ingestion CLI, and starts Vite. It never +uses `supabase db reset`, so it is safe to repeat without deleting personal data. The browser sees +only the publishable key plus a local Personal Mode session credential; the service-role key stays +in the companion/ingestion process. Public password authentication remains in the codebase but is +not rendered while `VITE_PERSONAL_MODE=true`. + - **Container runtime**: Colima (Docker CLI + Colima via Homebrew), not Docker Desktop — the lightweight open-source combination, chosen per explicit instruction. Started with `colima start --cpu 4 --memory 4 --disk 60` — an example allocation only; adjust the diff --git a/docs/SECURITY_AND_PRIVACY.md b/docs/SECURITY_AND_PRIVACY.md index 500b70b..840dcda 100644 --- a/docs/SECURITY_AND_PRIVACY.md +++ b/docs/SECURITY_AND_PRIVACY.md @@ -12,6 +12,15 @@ want exposed to anyone else, including a future second user of the same system. ## 2. Authentication +**Local Personal Mode (Phase 2A corrective release)**: normal owner use does not display an +authentication screen. `npm run personal` creates or reuses the local `dev-owner@careeros.local` +Auth user and persists a generated, local-only password in gitignored runtime files. The Vite +client uses that credential solely to acquire a regular, short-lived, RLS-scoped Supabase session; +it never receives a service-role key. This is deliberately limited to the loopback local runtime, +where it removes a repeated manual sign-in ceremony for the repository owner. Setting +`VITE_PERSONAL_MODE` off restores the dormant public-auth UI for a future hosted deployment; +the database ownership and RLS model is unchanged. + **Implemented as of Phase 0**: Supabase Auth with email/password (magic link and OAuth/social login are not implemented — see [ADR-008](adr/ADR-008-private-first-multi-user-later.md) and [DEPLOYMENT_STRATEGY.md](DEPLOYMENT_STRATEGY.md) for why password auth was chosen). Signs in via diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5146281 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,15 @@ +{ + "name": "careeros", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "careeros", + "version": "0.0.0", + "engines": { + "node": "22.23.1" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..fb26372 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "careeros", + "private": true, + "version": "0.0.0", + "type": "module", + "engines": { + "node": "22.23.1" + }, + "scripts": { + "personal": "node scripts/personal.mjs", + "personal:runtime": "node scripts/personal-runtime.mjs" + } +} diff --git a/scripts/ensure-personal-owner.mjs b/scripts/ensure-personal-owner.mjs new file mode 100644 index 0000000..cd46aa8 --- /dev/null +++ b/scripts/ensure-personal-owner.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node +/** + * Personal Mode's local-only identity bootstrap. + * + * This deliberately keeps Supabase Auth and the existing user_id/RLS model + * intact. It reuses the original local development owner when present, so a + * transition to Personal Mode cannot orphan that owner's existing data. + */ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { randomBytes } from 'node:crypto' +import { join } from 'node:path' + +const root = new URL('..', import.meta.url) +const runtimeDir = new URL('../.careeros/', import.meta.url) +const runtimeFile = new URL('../.careeros/personal.env', import.meta.url) + +const supabaseUrl = process.env.SUPABASE_URL +const secretKey = process.env.SUPABASE_SECRET_KEY + +if (!supabaseUrl || !secretKey) { + throw new Error('Personal Mode could not read the local Supabase runtime configuration.') +} + +const host = new URL(supabaseUrl).hostname +if (!['127.0.0.1', 'localhost', '::1'].includes(host)) { + throw new Error('Personal Mode only manages a loopback Supabase stack.') +} + +async function readRuntime() { + try { + const raw = await readFile(runtimeFile, 'utf8') + return Object.fromEntries( + raw + .split(/\r?\n/) + .filter(Boolean) + .map((line) => line.split(/=(.*)/s).slice(0, 2)), + ) + } catch (error) { + if (error && typeof error === 'object' && error.code === 'ENOENT') return {} + throw error + } +} + +function headers(extra = {}) { + return { + apikey: secretKey, + Authorization: `Bearer ${secretKey}`, + 'Content-Type': 'application/json', + ...extra, + } +} + +async function api(path, options = {}) { + const response = await fetch(`${supabaseUrl}${path}`, { + ...options, + headers: headers(options.headers), + }) + if (!response.ok) { + throw new Error(`Local owner setup failed (${response.status}).`) + } + return response +} + +async function findUser(email) { + const response = await api('/auth/v1/admin/users?page=1&per_page=200') + const body = await response.json() + const users = body.users ?? body + return users.find((user) => user.email === email) ?? null +} + +async function ensureProfile(userId) { + const response = await api(`/rest/v1/profiles?user_id=eq.${userId}&select=user_id`) + const rows = await response.json() + if (rows.length > 0) return + await api('/rest/v1/profiles', { + method: 'POST', + headers: { Prefer: 'return=minimal' }, + body: JSON.stringify({ user_id: userId, headline: 'Personal CareerOS workspace' }), + }) +} + +const runtime = await readRuntime() +const email = runtime.PERSONAL_OWNER_EMAIL ?? 'dev-owner@careeros.local' +const password = runtime.PERSONAL_OWNER_PASSWORD ?? randomBytes(24).toString('base64url') +const existing = await findUser(email) + +let userId +if (existing) { + userId = existing.id + // The stored local-only credential gives the browser an invisible session. + // Resetting to the same value is idempotent and leaves this user's rows intact. + await api(`/auth/v1/admin/users/${userId}`, { + method: 'PUT', + body: JSON.stringify({ password, email_confirm: true }), + }) +} else { + const response = await api('/auth/v1/admin/users', { + method: 'POST', + body: JSON.stringify({ email, password, email_confirm: true }), + }) + userId = (await response.json()).id +} + +await ensureProfile(userId) +await mkdir(runtimeDir, { recursive: true, mode: 0o700 }) +await writeFile( + runtimeFile, + `PERSONAL_OWNER_EMAIL=${email}\nPERSONAL_OWNER_PASSWORD=${password}\n`, + { mode: 0o600 }, +) + +// This line is intentionally machine-readable for the parent launcher and +// contains no service-role credential or password. +console.log(JSON.stringify({ email, userId })) diff --git a/scripts/personal-runtime.mjs b/scripts/personal-runtime.mjs new file mode 100644 index 0000000..78474d4 --- /dev/null +++ b/scripts/personal-runtime.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node +/** + * Loopback-only companion for Personal Mode. The browser can ask it to run + * ingestion, but it never receives the service-role key used by the CLI. + */ +import { createServer } from 'node:http' +import { spawn } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +const root = fileURLToPath(new URL('..', import.meta.url)) +const host = '127.0.0.1' +const port = Number(process.env.CAREEROS_RUNTIME_PORT ?? 4798) +let activeRefresh = null +let status = { + state: 'idle', + lastCompletedAt: null, + result: null, + error: null, +} + +function conciseOutput(output) { + const match = output.match(/fetched (\d+) record\(s\)[\s\S]*?-- (\d+) new, (\d+) updated, (\d+) unchanged, (\d+) error\(s\)/) + if (!match) return { checked: 0, created: 0, updated: 0, unchanged: 0, errors: 0 } + return { + checked: Number(match[1]), + created: Number(match[2]), + updated: Number(match[3]), + unchanged: Number(match[4]), + errors: Number(match[5]), + } +} + +function ingest() { + if (activeRefresh) return activeRefresh + status = { ...status, state: 'refreshing', error: null } + activeRefresh = new Promise((resolve) => { + const child = spawn('npm', ['run', 'ingest', '--', '--source', 'greenhouse'], { + cwd: `${root}/ingestion`, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }) + let output = '' + let errors = '' + child.stdout.on('data', (chunk) => (output += chunk)) + child.stderr.on('data', (chunk) => (errors += chunk)) + child.on('close', (code) => { + if (code === 0) { + status = { + state: 'ready', + lastCompletedAt: new Date().toISOString(), + result: conciseOutput(output), + error: null, + } + } else { + status = { + ...status, + state: 'failed', + error: 'The configured sources could not be refreshed. Check that your local runtime is running and try again.', + } + } + activeRefresh = null + resolve(status) + }) + }) + return activeRefresh +} + +function send(response, code, body) { + response.writeHead(code, { + 'Content-Type': 'application/json; charset=utf-8', + 'Access-Control-Allow-Origin': 'http://127.0.0.1:5173', + 'Cache-Control': 'no-store', + }) + response.end(JSON.stringify(body)) +} + +createServer(async (request, response) => { + if (request.method === 'OPTIONS') { + response.writeHead(204, { 'Access-Control-Allow-Origin': 'http://127.0.0.1:5173', 'Access-Control-Allow-Methods': 'GET, POST' }) + response.end() + return + } + if (request.url === '/status' && request.method === 'GET') return send(response, 200, status) + if (request.url === '/refresh' && request.method === 'POST') { + void ingest() + return send(response, 202, status) + } + return send(response, 404, { error: 'Not found' }) +}).listen(port, host, () => { + console.log(`CareerOS local refresh service listening on http://${host}:${port}`) +}) + +if (process.env.CAREEROS_REFRESH_ON_START === '1') void ingest() diff --git a/scripts/personal.mjs b/scripts/personal.mjs new file mode 100644 index 0000000..0637cd6 --- /dev/null +++ b/scripts/personal.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node +/** Personal-first launcher. It is intentionally non-destructive: no db reset. */ +import { execFileSync, spawn, spawnSync } from 'node:child_process' +import { existsSync, writeFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +const root = fileURLToPath(new URL('..', import.meta.url)) +const appDir = `${root}/app` +const ingestionDir = `${root}/ingestion` +const localUrl = 'http://127.0.0.1:54321' + +function run(command, args, options = {}) { + return execFileSync(command, args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...options }) +} + +function fail(message) { + console.error(`\nCareerOS could not start: ${message}`) + process.exit(1) +} + +const docker = spawnSync('docker', ['info'], { stdio: 'ignore' }) +if (docker.error || docker.status !== 0) { + fail('Docker or Colima is not reachable. Start Colima (for example, `colima start`) and run `npm run personal` again.') +} + +try { + run('supabase', ['start']) + // Applies only unapplied migrations to the running local stack; it never + // drops tables or calls db reset, preserving personal data on every run. + run('supabase', ['migration', 'up', '--local']) +} catch { + fail('Supabase could not start or apply pending migrations. Verify the Supabase CLI is installed and try again.') +} + +let status +try { + status = run('supabase', ['status', '-o', 'env']) +} catch { + fail('The local Supabase stack did not provide its runtime configuration.') +} +const env = Object.fromEntries(status.split(/\r?\n/).filter(Boolean).map((line) => { + const index = line.indexOf('=') + return [line.slice(0, index), line.slice(index + 1).replace(/^"|"$/g, '')] +})) +const publishableKey = env.PUBLISHABLE_KEY ?? env.ANON_KEY +const serviceRoleKey = env.SECRET_KEY ?? env.SERVICE_ROLE_KEY +if (!publishableKey || !serviceRoleKey) fail('Supabase did not provide the required local API keys.') + +try { + const owner = JSON.parse(run('node', ['scripts/ensure-personal-owner.mjs'], { + env: { ...process.env, SUPABASE_URL: env.API_URL ?? localUrl, SUPABASE_SECRET_KEY: serviceRoleKey }, + })) + const runtime = await import('node:fs/promises').then(({ readFile }) => readFile(`${root}/.careeros/personal.env`, 'utf8')) + const password = runtime.match(/^PERSONAL_OWNER_PASSWORD=(.+)$/m)?.[1] + if (!password) throw new Error('missing personal credential') + writeFileSync(`${appDir}/.env.local`, [ + `VITE_SUPABASE_URL=${env.API_URL ?? localUrl}`, + `VITE_SUPABASE_PUBLISHABLE_KEY=${publishableKey}`, + 'VITE_PERSONAL_MODE=true', + `VITE_PERSONAL_OWNER_EMAIL=${owner.email}`, + `VITE_PERSONAL_OWNER_PASSWORD=${password}`, + 'VITE_PERSONAL_RUNTIME_URL=http://127.0.0.1:4798', + '', + ].join('\n'), { mode: 0o600 }) +} catch { + fail('The local personal owner could not be prepared.') +} +writeFileSync(`${ingestionDir}/.env.local`, [ + `SUPABASE_URL=${env.API_URL ?? localUrl}`, + `SUPABASE_SERVICE_ROLE_KEY=${serviceRoleKey}`, + '', +].join('\n'), { mode: 0o600 }) + +for (const dir of [appDir, ingestionDir]) { + if (!existsSync(`${dir}/node_modules`)) { + const installed = spawnSync('npm', ['ci'], { cwd: dir, stdio: 'inherit' }) + if (installed.status !== 0) fail(`Dependencies could not be installed in ${dir}.`) + } +} + +const runtime = spawn('node', ['scripts/personal-runtime.mjs'], { + cwd: root, + env: { ...process.env, CAREEROS_REFRESH_ON_START: '1' }, + stdio: 'inherit', +}) +const vite = spawn('npm', ['run', 'dev', '--', '--host', '127.0.0.1'], { cwd: appDir, stdio: 'inherit' }) + +console.log('\nCareerOS is starting in Personal Mode at http://127.0.0.1:5173') +function stop() { + runtime.kill('SIGTERM') + vite.kill('SIGTERM') +} +process.on('SIGINT', stop) +process.on('SIGTERM', stop) +vite.on('exit', (code) => process.exit(code ?? 0)) From 9b5e024266e9c5fe67315b53fa41a5981207104a Mon Sep 17 00:00:00 2001 From: Abdulrahman Date: Mon, 10 Aug 2026 15:37:01 +0300 Subject: [PATCH 2/4] feat: redesign CareerOS application shell --- app/src/index.css | 1245 ++++++++++++++++- app/src/pages/ManualOpportunityFormPage.tsx | 2 +- .../pages/PrivateOpportunityDetailPage.tsx | 10 +- app/src/pages/ProfilePage.test.tsx | 6 +- app/src/pages/ProfilePage.tsx | 70 +- 5 files changed, 1239 insertions(+), 94 deletions(-) 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/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/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.

+ + 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 +
  • + ))} +
)}
From 3eaf5b722591f46ef857c486ba49ed1078f3c6bb Mon Sep 17 00:00:00 2001 From: Abdulrahman Date: Mon, 10 Aug 2026 15:37:23 +0300 Subject: [PATCH 4/4] feat: redesign applications and profile --- app/src/pages/ApplicationDetailPage.tsx | 2 +- app/src/pages/ApplicationsListPage.tsx | 317 ++++++++++++++---------- 2 files changed, 189 insertions(+), 130 deletions(-) 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 + + ) })} -
+ )}
)