Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 97 additions & 39 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,84 +20,141 @@ Track applications across platforms, manage companies, schedule interviews, and
---
<br/>

**WIP**: This is a work in progress. The project is not yet ready for production, changes are expected. It is still stable to use. Database migrations (Alembic) support schema updates across versions.
**WIP.** This is a work in progress. The project is not yet ready for production, breaking changes are possible. It is stable to use. Database migrations support schema updates across versions.


## What it does
## Stack

| Layer | Tech |
|-------|------|
| Frontend | Next.js 16, React 19, Tailwind CSS, shadcn/ui, Turborepo |
| Backend | FastAPI, SQLAlchemy, Alembic |
| Data | PostgreSQL |
| Runtime | Docker |
| Testing | Vitest + React Testing Library, Pytest |


## Features

### Dashboard

Default home when you load the app (`/dashboard`).

- Application count (with offers/rejections), response rate, average days per stage
- Week strip of upcoming appointments
- Stage distribution (pie chart), recent applications, platform conversion ranking, weekly heatmap
- Hide any widget from Settings

### Applications

Primary list for every application you track.

- Filter by status, stage, platform, company, and active vs archived; column sort; pagination
- Stage history (add, edit, remove entries)
- Archive and restore
- Detail view from the job title
- Create and edit: job title, company, platform, posting URL, contract type, seniority, salary, applied date, stage, status, resume
- Linked resume; appointments scoped to that application
- Extra columns and compact rows (Settings)

### Companies

Directory of employers.

- Name, website, notes
- New company: links applications that already used the same company text but were not linked yet
- Rename company: updates the company text on applications already linked to that record

### Platforms

Job boards and career sites you record applications against.

| Area | Capabilities |
|------|--------------|
| **Applications** | Job title, company, platform, salary, seniority, stages, history, archiving |
| **Companies** | Name, website, notes, linked to applications |
| **Platforms** | Job boards (LinkedIn, Indeed, etc.) with templates for quick entry |
| **Profile** | Resumes and profile data for fast attachment to applications |
| **Calendar** | Appointments and interviews with meeting URLs |
| **Dashboard** | Summary cards, status distribution, platform ranking, weekly heatmap |
- Dedicated page: create, edit, and delete platforms
- Each row: name, optional icon, base URL and "applications" URL (open from the table), **manual resume** flag for boards where you fill a CV on their site
- Built-in **templates** pre-fill name, icon, and URLs when you add or edit a platform
- Applications pick a platform; the applications list can filter by platform

### Calendar

Full-month schedule.

- Month grid and agenda views
- Event types: interview, assessment, project, meeting, other
- Optional meeting URL and optional link to an application

### Profile

Resumes and text you reuse on forms.

- CV upload, rename, archive, delete, download
- Presets: full name, email, phone, LinkedIn, GitHub, portfolio
- Customizable key/value rows

### Settings

Global display and layout.

- 12h or 24h, timezone, locale
- Dashboard: show or hide each widget; week strip starts expanded or collapsed
- Applications table: optional resume, salary, seniority, and created-at columns; compact row density

---

## Stack

| Layer | Tech |
|-------|------|
| Frontend | Next.js 16, React 19, Tailwind, shadcn/ui, Turborepo |
| Frontend | Next.js 16, React 19, Tailwind CSS, shadcn/ui, Turborepo |
| Backend | FastAPI, SQLAlchemy, Alembic |
| Data | PostgreSQL |
| Runtime | Docker |
| Testing | Vitest + React Testing Library, Pytest |


## Setup

### Docker
You can run the application via Docker or locally.


**Note:**
Running migrations is required on first setup. Also run after pulling updates that include new migrations.

**Prerequisites:** Docker
**Prerequisites:**
- Setup up `backend/.env` (see `backend/.env.example`)
- Setup up `frontend/.env` (see `frontend/.env.example`)



## Docker

```bash
git clone https://github.com/leobrqz/JobAppliesTracker.git
cd JobAppliesTracker
```

Create `backend/.env`:

```env
DATABASE_URL=postgresql://postgres:root@localhost:5432/jobtracker
STORAGE_DIR=./storage
```
Start the services:

```bash
docker compose up --build -d
```

Run migrations (required on first setup; also run after pulling updates that include new migrations):
Run migrations:

```bash
docker exec jobappliestracker-backend alembic upgrade head
```

| Service | URL |
|---------|-----|
| App | [http://localhost:3000](http://localhost:3000) |
| API | [http://localhost:8000](http://localhost:8000) |
| API docs | [http://localhost:8000/docs](http://localhost:8000/docs) |

### Local Environment
## Local Environment

**Prerequisites:** Node 20+, pnpm, Python 3.11+, PostgreSQL

1. Start PostgreSQL. Create database `jobtracker`:

```
createdb jobtracker
```

2. Create `backend/.env`:
### 1. PostgreSQL
Start PostgreSQL and create database `jobtracker`

```
DATABASE_URL=postgresql://postgres:root@localhost:5432/jobtracker
STORAGE_DIR=./storage
```

3. Backend:
### 2. Backend

**Prerequisites:** Set up Python virtual environment.

Expand All @@ -106,17 +163,18 @@ cd backend
pip install -r requirements.txt
```

Run migrations (required on first setup; also run after pulling updates that include new migrations):
Run migrations:

```bash
alembic upgrade head
```

Run the backend:
```bash
uvicorn app.main:app --reload
```

4. Frontend (another terminal):
### 3. Frontend:

```bash
cd frontend
Expand Down
11 changes: 8 additions & 3 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
DATABASE_URL=postgresql://postgres:root@localhost:5432/jobtracker
# Local
DATABASE_URL=postgresql://{DB_USER}:{DB_PASSWORD}@localhost:5432/jobtracker
STORAGE_DIR=./storage
# Comma-separated browser origins allowed for CORS (must match your Next.js URL)
CORS_ORIGINS=http://localhost:3000
CORS_ORIGINS=http://localhost:3000 # must match your Next.js URL

# Docker
DATABASE_URL=postgresql://{DB_USER}:{DB_PASSWORD}@postgres:5432/jobtracker
STORAGE_DIR=./storage
CORS_ORIGINS=http://localhost:3000 # must match your Next.js URL
16 changes: 15 additions & 1 deletion backend/app/routes/application_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@
from sqlalchemy.orm import Session

from app.core.database import get_db
from app.schemas.application_history import ApplicationHistoryCreate, ApplicationHistoryResponse
from app.schemas.application_history import (
ApplicationHistoryCreate,
ApplicationHistoryResponse,
ApplicationHistoryUpdate,
)
from app.services import application_history as history_service

router = APIRouter(prefix="/api/applications", tags=["application-history"])
Expand All @@ -20,6 +24,16 @@ def add_history_entry(
return history_service.advance_stage(db, application_id, data)


@router.patch("/{application_id}/history/{history_id}", response_model=ApplicationHistoryResponse)
def patch_history_entry(
application_id: int,
history_id: int,
data: ApplicationHistoryUpdate,
db: Session = Depends(get_db),
) -> ApplicationHistoryResponse:
return history_service.update_history_entry(db, application_id, history_id, data)


@router.delete("/{application_id}/history/{history_id}", status_code=204)
def delete_history_entry(
application_id: int, history_id: int, db: Session = Depends(get_db)
Expand Down
6 changes: 6 additions & 0 deletions backend/app/schemas/application_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ class ApplicationHistoryCreate(BaseModel):
notes: Optional[str] = None


class ApplicationHistoryUpdate(BaseModel):
stage: Optional[str] = None
date: Optional[datetime] = None
notes: Optional[str] = None


class ApplicationHistoryResponse(BaseModel):
model_config = {"from_attributes": True}

Expand Down
74 changes: 63 additions & 11 deletions backend/app/services/application_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@

from app.models.application import Application
from app.models.application_history import ApplicationHistory
from app.schemas.application_history import ApplicationHistoryCreate
from app.core import utcnow
from app.schemas.application_history import ApplicationHistoryCreate, ApplicationHistoryUpdate


def get_history(db: Session, application_id: int) -> list[ApplicationHistory]:
Expand All @@ -16,6 +15,18 @@ def get_history(db: Session, application_id: int) -> list[ApplicationHistory]:
)


def _get_latest_history_entry(
db: Session,
application_id: int,
*,
exclude_history_id: int | None = None,
) -> ApplicationHistory | None:
q = db.query(ApplicationHistory).filter(ApplicationHistory.application_id == application_id)
if exclude_history_id is not None:
q = q.filter(ApplicationHistory.id != exclude_history_id)
return q.order_by(ApplicationHistory.date.desc()).first()


def _append_history_and_update_stage(
db: Session,
application: Application,
Expand Down Expand Up @@ -52,6 +63,55 @@ def advance_stage(db: Session, application_id: int, data: ApplicationHistoryCrea
return entry


def update_history_entry(
db: Session,
application_id: int,
history_id: int,
data: ApplicationHistoryUpdate,
) -> ApplicationHistory:
entry = (
db.query(ApplicationHistory)
.filter(
ApplicationHistory.id == history_id,
ApplicationHistory.application_id == application_id,
)
.first()
)
if entry is None:
raise HTTPException(status_code=404, detail="History entry not found")

updates = data.model_dump(exclude_unset=True)
if not updates:
raise HTTPException(status_code=400, detail="At least one field must be provided")

if "stage" in updates:
stage_val = updates["stage"]
if stage_val is None or (isinstance(stage_val, str) and not stage_val.strip()):
raise HTTPException(status_code=400, detail="Stage cannot be empty")
entry.stage = stage_val.strip()

if "date" in updates:
date_val = updates["date"]
if date_val is None:
raise HTTPException(status_code=400, detail="Date cannot be null")
entry.date = date_val

if "notes" in updates:
entry.notes = updates["notes"]

application = db.query(Application).filter(Application.id == application_id).first()
if application is None:
raise HTTPException(status_code=404, detail="Application not found")

latest = _get_latest_history_entry(db, application_id)
if latest is not None:
application.current_stage = latest.stage

db.commit()
db.refresh(entry)
return entry


def delete_history_entry(db: Session, application_id: int, history_id: int) -> bool:
entry = (
db.query(ApplicationHistory)
Expand All @@ -77,15 +137,7 @@ def delete_history_entry(db: Session, application_id: int, history_id: int) -> b

db.delete(entry)

latest = (
db.query(ApplicationHistory)
.filter(
ApplicationHistory.application_id == application_id,
ApplicationHistory.id != history_id,
)
.order_by(ApplicationHistory.date.desc())
.first()
)
latest = _get_latest_history_entry(db, application_id, exclude_history_id=history_id)
application = db.query(Application).filter(Application.id == application_id).first()
if application and latest:
application.current_stage = latest.stage
Expand Down
1 change: 1 addition & 0 deletions frontend/apps/web/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
NEXT_PUBLIC_API_URL=http://localhost:8000 # must match your API URL
Loading
Loading