Frontend¶
Technology Stack¶
- SolidJS 1.9 - UI framework (fine-grained reactivity, no virtual DOM)
- TypeScript 5.9 - type safety (
erasableSyntaxOnlyenabled) - Vite 7 - build tool
- @solidjs/router - client-side routing
- TanStack Solid Query - server state management
- openapi-fetch - type-safe API client (generated from OpenAPI specs)
- oidc-client-ts - Keycloak OIDC authentication
- @solid-primitives/i18n - internationalization (EN/PL)
- solid-icons - icon library
- CSS Modules - component styling
- @jankrajewskiit/ui - shared UI component library (published to GitHub Packages)
Build & Run¶
cd recron-web && npm install # install dependencies
cd recron-web && npm run dev # dev server (port 5173)
cd recron-web && npm run build # tsc -b && vite build
cd recron-web && npx tsc --noEmit # typecheck only
cd recron-web && npm run lint # eslint .
cd recron-web && npm run lint:fix # eslint . --fix
OpenAPI Type Generation¶
Three microservices expose separate OpenAPI specs through the YARP gateway at http://localhost:5000. Generated types provide:
- Typed URL paths — compile-time validation of endpoint URLs
- Typed request bodies — field names and types checked at compile time
- IDE autocomplete — available endpoints suggested when typing
Commands¶
# Generate types for all services (run from recron-web/)
npx openapi-typescript http://localhost:5000/organizer/openapi/v1.json -o src/api/schema/organizer.d.ts
npx openapi-typescript http://localhost:5000/questions/openapi/v1.json -o src/api/schema/questions.d.ts
npx openapi-typescript http://localhost:5000/users/openapi/v1.json -o src/api/schema/users.d.ts
When to regenerate
Re-run these commands whenever backend endpoints, request bodies, or response schemas change.
The backend must be running (via dotnet run in Recron.AppHost) for the specs to be accessible.
Generated files¶
| File | Source | Description |
|---|---|---|
src/api/schema/organizer.d.ts |
Organizer.Api | Offers, interviews, dictionaries, AI parsing, stats |
src/api/schema/questions.d.ts |
Questions.Api | Categories, questions, AI, quiz |
src/api/schema/users.d.ts |
Users.Api | Token details, user profile |
Missing response schemas
Most backend endpoints currently return 200 OK without a declared response body schema.
The generated types have content?: never for these responses, so we use manually-ported
model types in src/models/ and cast responses: data as unknown as Offer[].
If the backend adds proper Produces<T>() declarations, regenerating the types will
eliminate the need for these casts.
Project Structure¶
recron-web/
├── src/
│ ├── api/
│ │ ├── client.ts # Per-service openapi-fetch clients with auth middleware
│ │ ├── query.ts # TanStack QueryClient config
│ │ └── schema/ # Auto-generated OpenAPI types (DO NOT edit manually)
│ │ ├── organizer.d.ts
│ │ ├── questions.d.ts
│ │ └── users.d.ts
│ ├── components/
│ │ ├── common/ # Recron-specific UI components
│ │ │ └── RequireAuth/ # Role-based auth guard + AuthRedirect (uses Recron auth)
│ │ │
│ │ │ # All UI primitives (Button, Dialog, Input, LocaleSwitcher, ThemeToggle, etc.)
│ │ │ # and layout shells (Navbar, Footer, MainLayout) come from @jankrajewskiit/ui.
│ │ │ # See: docs/technical/shared-packages.md
│ │ ├── layout/ # Thin wrappers wiring @jankrajewskiit/ui layout to Recron stores
│ │ │ ├── MainLayout/ # Wraps VelvetUi MainLayout with auth-conditional navbar + i18n footer
│ │ │ │ └── MainLayout.tsx
│ │ │ └── Navbar/ # Wraps VelvetUi Navbar with i18n labels + app-specific slots
│ │ │ ├── Navbar.tsx # Thin wrapper: resolves i18n, passes to VelvetUi Navbar
│ │ │ ├── NavItem.ts # NavItem config with DictKey labels + resolveNavItems()
│ │ │ ├── NavLogo.tsx # Recron logo icon (passed as logo slot)
│ │ │ └── AuthButton.tsx # Login/logout toggle (passed as actions slot)
│ │ ├── organizer/ # Job organizer feature
│ │ │ ├── Organizer.tsx # Feature container
│ │ │ ├── Dashboard/ # Statistics dashboard
│ │ │ │ └── Dashboard.tsx # Stats overview (totals, charts)
│ │ │ ├── OfferTable.tsx # Offer table
│ │ │ ├── OfferTableRow.tsx # Table row
│ │ │ ├── OfferDrawer.tsx # Offer detail drawer
│ │ │ ├── DrawerContent.tsx # Drawer tabs (details, interviews, AI match)
│ │ │ ├── StatusTabs.tsx # Status filter tabs
│ │ │ ├── AddOfferWizard.tsx # Add offer (manual / URL / text AI parse)
│ │ │ ├── DefineOfferDialog.tsx # Edit offer dialog
│ │ │ ├── DeleteOffer.tsx # Delete offer confirmation
│ │ │ ├── ChangeStateDialog.tsx # Status change dialog
│ │ │ ├── OfferFormFields.tsx # Shared form fields
│ │ │ ├── SalaryFields.tsx # Salary form fields
│ │ │ ├── ReasonsAutocomplete.tsx # Reasons selector
│ │ │ ├── RedirectToOffer.tsx # External link button
│ │ │ ├── MatchCvPanel.tsx # AI CV-to-offer matching panel
│ │ │ ├── InterviewTimeline.tsx # Interview timeline
│ │ │ ├── InterviewTimelineItem.tsx # Timeline entry
│ │ │ ├── InterviewCard/ # Interview card with actions
│ │ │ │ └── InterviewCard.tsx # Card with markdown notes
│ │ │ ├── InterviewStatusMenu/ # Interview status change
│ │ │ │ └── InterviewStatusMenu.tsx
│ │ │ ├── AddInterview.tsx # Add interview dialog
│ │ │ ├── AddToCalendar.tsx # Google Calendar link
│ │ │ └── DragDropOffers.tsx # Drag & drop reorder
│ │ ├── portfolio/ # CV portfolio builder
│ │ │ ├── Portfolio.tsx # Main orchestrator (editor/preview tabs)
│ │ │ ├── PersonalInfoForm/ # Personal info grid form
│ │ │ ├── SummaryForm/ # Professional summary editor
│ │ │ ├── ExperienceForm/ # Work experience CRUD list
│ │ │ ├── ProjectsForm/ # Projects CRUD list
│ │ │ ├── SkillsForm/ # Skills inline CRUD
│ │ │ ├── EducationForm/ # Education CRUD list
│ │ │ ├── CertificationsForm/ # Certifications CRUD list
│ │ │ ├── LanguagesForm/ # Languages inline CRUD
│ │ │ ├── SectionCard/ # Collapsible accordion section
│ │ │ ├── VariantSelector/ # Portfolio variant management
│ │ │ ├── ImportPortfolioDialog/ # Import from LinkedIn/PDF
│ │ │ ├── CvPreview/ # Minimalist single-column CV template
│ │ │ ├── TwoColumnCvPreview/ # Two-column CV template
│ │ │ └── BurgundyCvPreview/ # Burgundy-themed CV template
│ │ ├── questions/ # Questions feature
│ │ │ ├── Questions.tsx # Feature container
│ │ │ ├── QuestionItem.tsx # Question card
│ │ │ ├── QuestionAnswer.tsx # Answer display + Ask AI
│ │ │ ├── QuestionFilters.tsx # Filter by status (all/learning/pending)
│ │ │ ├── ProposeQuestionButton.tsx # Propose new question
│ │ │ ├── Categories.tsx # Category tree
│ │ │ ├── MainCategories.tsx # Top-level categories
│ │ │ ├── MainCategory.tsx # Category card
│ │ │ ├── ChildCategories.tsx # Subcategories
│ │ │ ├── Quiz/ # Quiz / flashcard system
│ │ │ │ ├── Quiz.tsx # Quiz orchestrator (setup→session→summary)
│ │ │ │ ├── QuizSetup/ # Category + count selector
│ │ │ │ ├── QuizSession/ # Progress bar + flashcard rating
│ │ │ │ ├── QuizSummary/ # Results with percentage
│ │ │ │ └── Flashcard/ # Flip card (question/answer)
│ │ │ └── actions/ # Question action dialogs
│ │ │ ├── QuestionActions.tsx
│ │ │ ├── EditQuestionDialog.tsx
│ │ │ └── EditAnswerDialog.tsx
│ │ └── settings/ # Settings feature
│ │ └── Profile/ # User profile
│ │ ├── Profile.tsx # Profile page wrapper
│ │ └── ProfileForm.tsx # Editable profile form
│ ├── hooks/
│ │ └── useAddOfferWizard.ts # Add offer wizard state (Recron-specific)
│ │ # useClickOutside and useEscapeKey come from @jankrajewskiit/ui
│ ├── i18n/
│ │ └── locales/
│ │ ├── en.ts # English translations (source of truth)
│ │ └── pl.ts # Polish translations
│ ├── models/ # TypeScript interfaces & const enums
│ ├── pages/ # Route page components (lazy-loaded)
│ ├── store/ # State management (queries, mutations, signals)
│ ├── theme/ # Theming
│ │ └── globals.css # CSS custom properties (design tokens)
│ ├── App.tsx # Router + provider stack
│ └── index.tsx # Entry point
├── .env # Local environment variables
└── .env.example # Template
Routes¶
All page components are lazy-loaded via lazy(). Auth is enforced inside page components using <RequireAuth>, not at the router level.
| Route | Page Component | Auth Required | Roles | Description |
|---|---|---|---|---|
/ |
Home |
No | — | Homepage |
/privacy |
Privacy |
No | — | Privacy policy |
/questions |
Questions |
Yes | user, moderator, admin | Interview questions & learning |
/quiz |
Quiz |
Yes | user, moderator, admin | Quiz / flashcard sessions |
/organizer |
Organizer |
Yes | user, moderator, admin | Job offer tracking & dashboard |
/portfolio |
Portfolio |
Yes | user, moderator, admin | CV portfolio builder |
/settings |
Settings |
Yes | user, moderator, admin | User profile & preferences |
*404 |
NotFound |
No | — | 404 page |
Component Patterns¶
Props Convention¶
All components use the Props<T> utility type from @jankrajewskiit/ui which adds class?: string:
import { type Props } from "@jankrajewskiit/ui";
interface MyComponentProps {
title: string;
}
const MyComponent = (props: Props<MyComponentProps>) => {
return (
<div class={`${styles.wrapper} ${props.class ?? ""}`}>
{props.title}
</div>
);
};
export default MyComponent;
Rules¶
- Always use arrow functions for components — never function declarations
- Always use
Props<T>— never manually declareclass?: string - Always forward
classon the top-level element - NEVER destructure props — access as
props.nameto preserve SolidJS reactivity - One component per file, each in its own directory with co-located CSS Module
State Management¶
Store Files¶
| File | Purpose |
|---|---|
auth.tsx |
Keycloak OIDC provider (oidc-client-ts + SolidJS context) |
i18n.tsx |
i18n provider (EN/PL, lazy-loaded dictionaries) |
organizer.ts |
Offers signals, queries, mutations |
questions.ts |
Questions/categories signals, queries, mutations |
interviews.ts |
Interview queries and mutations |
dictionary.ts |
Dictionary queries (positions, cities, reasons) |
users.ts |
User profile query and save mutation |
portfolio.ts |
Portfolio store with localStorage persistence |
calendar.ts |
Google Calendar OAuth status and connect/disconnect |
tags.ts |
User-defined offer tags queries and mutations |
Query Hooks¶
// organizer.ts
useOfferCountsQuery() // Offer counts by status
useOffersStatsQuery() // Dashboard statistics
useOffersQuery() // All user offers
useCompareOffersQuery() // Compare selected offers side by side
// questions.ts
useCategoriesQuery() // Category tree
useQuestionsQuery() // Questions for selected category
useQuizQuery() // Quiz questions for a category + count
// interviews.ts
useInterviewsQuery() // Interviews for an offer
// dictionary.ts
usePositionsQuery() // Position dictionary
useCitiesQuery() // City dictionary
useReasonsQuery() // Rejection reason dictionary
// users.ts
useUserProfileQuery() // Current user profile
// portfolio.ts
usePortfolioVariantsQuery() // All portfolio variants for current user
// calendar.ts
useGoogleCalendarStatusQuery() // Check Google Calendar OAuth status
Mutation Hooks¶
// organizer.ts
useAddOfferMutation() // Create offer
useUpdateOfferMutation() // Edit offer
useUpdateOfferStateMutation() // Change offer status
useDeleteOfferMutation() // Delete offer
useDuplicateOfferMutation() // Duplicate offer
useParseOfferAiMutation() // AI parse offer from URL/text
useMatchCvMutation() // AI CV-to-offer matching
useScoreOfferMutation() // AI score offer compatibility
useGenerateCoverLetterMutation()// AI generate cover letter
// questions.ts
useAskAiMutation() // Generate AI answer
usePromoteQuestionMutation() // Upvote question
useAddToLearningMutation() // Add to learning list
useRemoveFromLearningMutation() // Remove from learning list
useUpdateQuestionMutation() // Edit question
useUpdateAnswerMutation() // Edit answer
useReorderQuestionMutation() // Reorder question
useProposeQuestionMutation() // Propose new question
useApproveQuestionMutation() // Approve pending question (moderator)
useRejectQuestionMutation() // Reject pending question (moderator)
useDeleteQuestionMutation() // Delete question (moderator)
// interviews.ts
useAddInterviewMutation() // Schedule interview
useUpdateInterviewMutation() // Update interview
useDeleteInterviewMutation() // Delete interview
// users.ts
useSaveProfileMutation() // Save user profile
// portfolio.ts
useParseLinkedInMutation() // Import from LinkedIn URL
useParseCvPdfMutation() // Import from CV PDF upload
useCheckAtsMutation() // AI ATS compatibility check
// calendar.ts
useConnectGoogleCalendarMutation() // Start Google Calendar OAuth flow
useDisconnectGoogleCalendarMutation() // Revoke Google Calendar tokens
Portfolio Store (Local-First Pattern)¶
The portfolio store uses a local-first approach different from the other stores:
- Portfolio data is managed via
createStore()with localStorage persistence - Changes are debounced (500ms) and synced to the API automatically
- Supports multiple named variants (create, load, delete, rename)
- Provides granular update functions:
updatePersonalInfo(),updateSummary(),addExperience(),updateExperience(),removeExperience(), and similar for projects, skills, education, certifications, languages - API communication uses
authFetch()(raw fetch with auth) instead of the openapi-fetch client
Mutation Pattern¶
All mutations use onSettled (never onSuccess) for query invalidation:
export const useDeleteOfferMutation = () => {
const queryClient = useQueryClient();
return createMutation(() => ({
mutationKey: ["offers", "delete"],
mutationFn: async (offerId: string) => {
await organizerApi.DELETE("/offers/{offerId}", {
params: { path: { offerId } },
});
},
onSettled: () => {
void queryClient.invalidateQueries({ queryKey: ["offers"] });
void queryClient.invalidateQueries({ queryKey: ["offerCounts"] });
},
}));
};
API Client Architecture¶
openapi-fetch Clients (Organizer, Questions, Users)¶
Three separate openapi-fetch clients, one per microservice, each with the gateway prefix baked into baseUrl:
// src/api/client.ts
export const organizerApi = createClient<OrganizerPaths>({
baseUrl: `${baseUrl}/organizer`,
});
organizerApi.use(authMiddleware); // injects Bearer token
The auth middleware calls getAccessToken() (from store/auth.tsx) on every request, handling silent token renewal when tokens expire.
authFetch (Portfolio)¶
Portfolio.Api uses a different pattern — a raw fetch wrapper with auth header injection:
// Used in portfolio store for all Portfolio.Api calls
const authFetch = async (url: string, options?: RequestInit) => {
const token = await getAccessToken();
return fetch(url, {
...options,
headers: { ...options?.headers, Authorization: `Bearer ${token}` },
});
};
Feature Components¶
Dashboard¶
src/components/organizer/Dashboard/Dashboard.tsx
Displays aggregated statistics for the user's job search:
- Summary cards — total offers, total interviews, conversion rate (%), average salary range
- Status distribution — chips showing counts per offer status
- Source breakdown — bar chart of offer sources (LinkedIn, NoFluffJobs, etc.)
- Work mode distribution — bar chart of remote/hybrid/onsite
- Interview stages — bar chart of interview counts by stage
- Weekly activity — vertical bar chart of recent offer/interview activity
Quiz / Flashcards¶
src/components/questions/Quiz/
Four-phase quiz flow:
- Setup (
QuizSetup) — select category from tree + number of questions - Loading — fetch quiz questions via
useQuizQuery() - Session (
QuizSession) — progress bar, flashcard display, know/don't-know rating per card - Summary (
QuizSummary) — shows percentage, known/unknown/total counts, restart option
The Flashcard component renders a flip card with the question on the front and the markdown-rendered answer on the back.
CV Portfolio Builder¶
src/components/portfolio/Portfolio.tsx
Full CV builder with:
- Editor tab — form sections (personal info, summary, experience, projects, skills, education, certifications, languages) in collapsible
SectionCardaccordions - Preview tab — live preview with 3 CV templates:
CvPreview— minimalist single-columnTwoColumnCvPreview— two-column (sidebar: contact/skills/languages/certs; main: summary/experience/projects/education)BurgundyCvPreview— burgundy-themed with dot-based skill/language levels
- Variant management (
VariantSelector) — create, rename, delete, switch between multiple CV versions - Import (
ImportPortfolioDialog) — import from LinkedIn URL or CV PDF upload (drag-and-drop) - Export — download as PDF, HTML, or JSON
AI CV-to-Offer Matching¶
src/components/organizer/MatchCvPanel.tsx
Located inside the offer drawer (DrawerContent.tsx), allows matching a user's CV against a job offer:
- Variant selector — choose which portfolio variant to match
- Match action — sends portfolio data + offer to AI via
useMatchCvMutation() - Result display — visual
ScoreRing(SVG percentage), summary text, strengths list, gaps list, recommendations list
User Profile¶
src/components/settings/Profile/
Profile.tsx— wrapper that shows auth state, rendersProfileForm+LocaleSwitcherunder preferencesProfileForm.tsx— editable form for: display name, bio, preferred role, experience years, location, skills (comma-separated), LinkedIn URL, GitHub URL, website URL
CSS Modules¶
/* Component.module.css */
.container {
padding: var(--spacing-md);
background: var(--color-surface);
border-radius: var(--radius-md);
}
import styles from "./Component.module.css";
const Component = (props: Props<ComponentProps>) => {
return (
<div class={`${styles.container} ${props.class ?? ""}`}>
<h2 class={styles.title}>{props.title}</h2>
</div>
);
};
Rules:
- One
.module.cssfile per component, co-located in the same directory - Use camelCase class names:
.menuItem,.addButton - Use CSS custom properties for theming:
var(--color-primary),var(--spacing-md) - Never import CSS modules from another component's directory
Enum Pattern¶
TypeScript 5.9 with erasableSyntaxOnly does not allow enum. Use const objects:
const OfferStatus = {
New: "New",
Considered: "Considered",
Sent: "Sent",
Rejected: "Rejected",
} as const;
type OfferStatus = (typeof OfferStatus)[keyof typeof OfferStatus];
export default OfferStatus;
Authentication¶
Keycloak OIDC via oidc-client-ts wrapped in a SolidJS context:
RequireAuth Guard¶
Auth is enforced inside page components (not at the router level). When unauthenticated, AuthRedirect automatically triggers sign-in. When the user lacks required roles, AccessDenied is shown.
Environment variables:
| Variable | Example |
|---|---|
VITE_AUTH_ISSUER |
https://auth.bluebraces.online/realms/recron |
VITE_AUTH_CLIENT_ID |
frontend |
VITE_AUTH_URL |
http://localhost:5173 |
VITE_API_BASE_URL |
http://localhost:5000 |
Internationalization¶
Translation Structure¶
// en.ts (source of truth for types via RawDictionary)
export default {
common: { cancel: "Cancel", save: "Save" },
organizer: {
offerStatus: { new: "New", considered: "Considered" },
interviewStage: { screening: "Screening", technical: "Technical" },
},
portfolio: { title: "CV Portfolio", ... },
quiz: { title: "Quiz", ... },
settings: { profile: { title: "Profile", ... } },
} as const;
Usage: t('key') for reactive translations, ts('key') when the return value must be string (e.g., template literals, aria-label).
Enum Translation Pattern¶
import { type DictKey } from "~/store/i18n";
export const OfferStatusTranslationKeys: Record<OfferStatus, DictKey> = {
[OfferStatus.New]: "organizer.offerStatus.new",
[OfferStatus.Considered]: "organizer.offerStatus.considered",
[OfferStatus.Sent]: "organizer.offerStatus.sent",
[OfferStatus.Rejected]: "organizer.offerStatus.rejected",
};
// Usage
const { t } = useI18n();
const label = t(OfferStatusTranslationKeys[offer.status]);
Domain Models¶
Offer¶
interface Offer {
id: string;
name: string;
link: string;
company: string;
city: string;
status: OfferStatus;
reasons: string[];
description: string | null;
source: string | null;
workMode: string | null;
intermediary: string | null;
salaryMin: number | null;
salaryMax: number | null;
salaryCurrency: string | null;
salaryType: string | null;
order: number;
isAiGenerated: boolean;
rawContent: string | null;
sourceUrl: string | null;
}
OffersStats¶
interface OffersStats {
totalOffers: number;
totalInterviews: number;
statusCounts: StatusCount[];
sourceCounts: SourceCount[];
workModeCounts: WorkModeCount[];
weeklyActivity: WeeklyActivity[];
interviewStageCounts: InterviewStageCount[];
}
Interview¶
interface Interview {
id: string;
offerId: string;
stage: InterviewStage;
status: InterviewStatus;
scheduledAt: string | null;
notes: string | null;
location: string | null;
duration: number | null;
}
Question¶
interface Question {
id: string;
name: string;
description: string | null;
promoteCount: number;
hasUserVoted: boolean;
isInLearning: boolean;
isPending: boolean;
}
QuizQuestion¶
Category (hierarchical)¶
interface Category {
id: string;
name: string;
description: string | null;
iconName: string | null;
childCategories: Category[];
}
PortfolioData¶
interface PortfolioData {
personalInfo: PersonalInfo;
summary: string;
experience: WorkExperience[];
projects: Project[];
skills: Skill[];
education: Education[];
certifications: Certification[];
languages: Language[];
}
interface PersonalInfo {
firstName: string;
lastName: string;
email: string;
phone: string;
location: string;
linkedIn: string;
website: string;
}
interface WorkExperience {
id: string;
company: string;
position: string;
startDate: string;
endDate: string;
current: boolean;
description: string;
}
interface Skill {
id: string;
name: string;
level: SkillLevel; // "Beginner" | "Intermediate" | "Advanced" | "Expert"
}
interface Language {
id: string;
name: string;
level: LanguageLevel; // "A1" | "A2" | "B1" | "B2" | "C1" | "C2" | "Native"
}
UserProfile¶
interface UserProfile {
displayName: string;
bio: string | null;
preferredRole: string | null;
experienceYears: number | null;
location: string | null;
skills: string | null;
linkedInUrl: string | null;
gitHubUrl: string | null;
websiteUrl: string | null;
}
MatchCvResult¶
interface MatchCvResult {
matchScore: number;
summary: string;
strengths: string[];
gaps: string[];
recommendations: string[];
}
ParseOfferRequest / ParseOfferResponse¶
interface ParseOfferRequest {
mode: ParseMode; // "Url" | "Text" | "Manual"
content: string;
}
interface ParseOfferResponse {
name: string;
company: string;
city: string;
link: string;
description: string | null;
source: string | null;
workMode: string | null;
intermediary: string | null;
salaryMin: number | null;
salaryMax: number | null;
salaryCurrency: string | null;
salaryType: string | null;
}
Const Enum Types¶
| Type | Values |
|---|---|
OfferStatus |
New, Considered, Sent, Rejected |
OfferSource |
LinkedIn, NoFluffJobs, JustJoinIT, Pracuj, Email, Direct, Other |
InterviewStage |
Screening, Technical, HR, Offer, Onboarding |
InterviewStatus |
Scheduled, Completed, Failed, Cancelled |
ParseMode |
Url, Text, Manual |
WorkMode |
Remote, Hybrid, Onsite |
SalaryType |
Monthly, Hourly, Annual |
SalaryCurrency |
PLN, EUR, USD |
SkillLevel |
Beginner, Intermediate, Advanced, Expert |
LanguageLevel |
A1, A2, B1, B2, C1, C2, Native |