Home / Side Quests / Apply Tracker

Apply Tracker

Estimated time: 5–6 hours | Difficulty: Intermediate

Side Quest

What You Will Build

  • A full Kanban board with drag-and-drop columns for tracking job applications
  • A new database table with a foreign key relationship to your existing favorite_jobs table
  • Backend endpoints for grouping applications by status and updating them
  • Native HTML5 drag and drop — no external libraries required
  • An application timeline that records every status change with timestamps
  • A stats dashboard showing your application metrics at a glance

1. What Is a Kanban Board?

Before you write a single line of code, you need to understand the tool you are building. A Kanban board is a visual workflow management system. The idea is beautifully simple: you have a set of columns that represent stages in a process, and cards that represent individual items. As an item progresses through the process, you drag its card from one column to the next.

The word "Kanban" comes from Japanese and roughly translates to "visual signal" or "signboard." It was originally developed by Toyota in the 1940s for manufacturing. The core insight was that visualizing work in progress makes it dramatically easier to manage. When you can see where everything stands at a glance, bottlenecks become obvious, nothing gets forgotten, and you always know what to do next.

You have almost certainly used a Kanban board already, even if you did not know the name. Trello is a Kanban board. Jira boards in agile software teams are Kanban boards. GitHub Projects uses a Kanban layout. Even a simple sticky-note wall with "To Do," "In Progress," and "Done" columns is a Kanban board. The pattern is everywhere because it works.

Columns = Stages, Cards = Items

Every Kanban board follows the same principle. The columns represent the stages of your workflow. The cards represent individual work items. Cards move left to right as they progress through the stages. At any moment, you can look at the board and instantly understand the status of every item.

For a job application tracker, the stages map perfectly to the job search process:

Why track rejections? It might feel counterintuitive to have a "Rejected" column. Why would you want to see your rejections? Because data removes emotion. When you can see that you applied to 40 jobs, got 12 phone screens, 6 interviews, and 2 offers, the 38 rejections stop feeling personal. They are just the normal math of job searching. Every professional goes through this. The board makes that visible and turns a stressful process into a manageable one.

By the end of this side quest, you will have a fully functional Kanban board where you can drag job cards between columns, see your application history as a timeline, and view stats about your overall job search progress. This is a real tool you can actually use — and it is an impressive portfolio piece that shows you understand both frontend interactivity and backend data management.

2. New Database Table

Your Resumator already has a favorite_jobs table from Lesson 26. Now you need a new table to track the application status of those jobs. This is a separate concern: a job being favorited is not the same as tracking where it stands in your hiring pipeline. You want a dedicated job_applications table that links back to the favorited job.

This will be the third foreign key relationship you have built in this course. By now, you know the pattern: one table references a row in another table using a foreign key column. The job_applications table will have a favorite_job_id column that points to the id column in favorite_jobs. This means every application record is tied to a specific saved job.

Why a Separate Table?

You could add a status column directly to the favorite_jobs table. That would work for the simplest case. But we want to track more than just the current status — we want a timeline of status changes, notes, and metadata specific to the application process. Keeping this in its own table follows the Single Responsibility Principle: the favorite_jobs table stores job data, and the job_applications table tracks your application journey.

Open MySQL Workbench and run the following SQL to create the table:

CREATE TABLE job_applications (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    favorite_job_id BIGINT NOT NULL,
    status VARCHAR(50) NOT NULL DEFAULT 'INTERESTED',
    notes TEXT,
    applied_date DATE,
    last_updated DATETIME NOT NULL,
    created_at DATETIME NOT NULL,

    CONSTRAINT fk_application_favorite
        FOREIGN KEY (favorite_job_id)
        REFERENCES favorite_jobs(id)
        ON DELETE CASCADE,

    CONSTRAINT chk_status
        CHECK (status IN (
            'INTERESTED',
            'APPLIED',
            'PHONE_SCREEN',
            'INTERVIEW',
            'OFFER',
            'REJECTED'
        ))
);

Let us break down the important parts of this table:

The foreign key (favorite_job_id): This column stores the id of a row in the favorite_jobs table. The FOREIGN KEY constraint enforces this relationship — you cannot insert an application that references a favorite job that does not exist. The ON DELETE CASCADE clause means that if a favorite job is deleted, all associated application records are automatically removed too. This prevents orphaned records.

The status column: This is a VARCHAR(50) with a CHECK constraint that limits the allowed values to exactly six strings: INTERESTED, APPLIED, PHONE_SCREEN, INTERVIEW, OFFER, and REJECTED. Using uppercase with underscores is a common convention for status values because they behave like constants — and in your Java code, they will map to an enum.

The default value: Notice DEFAULT 'INTERESTED'. When a new application row is created, if no status is specified, it starts as INTERESTED. This makes sense because the natural starting point is "I am interested in this job but have not applied yet."

Timestamps: The created_at field records when the application record was first created. The last_updated field records the most recent change. These will be essential for the timeline feature you build later.

Make sure favorite_jobs exists first The FOREIGN KEY constraint references the favorite_jobs table. If that table does not exist in your database yet, this CREATE TABLE statement will fail. Make sure you have completed Lesson 26 and created the favorite_jobs table before running this SQL.

3. Backend Endpoints

With the database table in place, you need two backend endpoints to power the Kanban board. The first endpoint retrieves all applications grouped by status — this is what the board will use to know which cards belong in which column. The second endpoint updates the status of a single application — this is what fires when you drag a card to a new column.

First, create the JobApplication entity and an enum for the status values. The enum ensures type safety — your Java code will only accept the six valid statuses, and the compiler will catch any typos:

package com.example.resumator.model;

public enum ApplicationStatus {
    INTERESTED,
    APPLIED,
    PHONE_SCREEN,
    INTERVIEW,
    OFFER,
    REJECTED
}

Now the entity class that maps to your new table:

package com.example.resumator.model;

import jakarta.persistence.*;
import java.time.LocalDate;
import java.time.LocalDateTime;

@Entity
@Table(name = "job_applications")
public class JobApplication {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "favorite_job_id", nullable = false)
    private FavoriteJob favoriteJob;

    @Enumerated(EnumType.STRING)
    @Column(name = "status", nullable = false, length = 50)
    private ApplicationStatus status = ApplicationStatus.INTERESTED;

    @Column(columnDefinition = "TEXT")
    private String notes;

    @Column(name = "applied_date")
    private LocalDate appliedDate;

    @Column(name = "last_updated", nullable = false)
    private LocalDateTime lastUpdated;

    @Column(name = "created_at", nullable = false)
    private LocalDateTime createdAt;

    @PrePersist
    protected void onCreate() {
        this.createdAt = LocalDateTime.now();
        this.lastUpdated = LocalDateTime.now();
    }

    @PreUpdate
    protected void onUpdate() {
        this.lastUpdated = LocalDateTime.now();
    }

    public JobApplication() {}

    // Getters and setters
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }

    public FavoriteJob getFavoriteJob() { return favoriteJob; }
    public void setFavoriteJob(FavoriteJob favoriteJob) {
        this.favoriteJob = favoriteJob;
    }

    public ApplicationStatus getStatus() { return status; }
    public void setStatus(ApplicationStatus status) { this.status = status; }

    public String getNotes() { return notes; }
    public void setNotes(String notes) { this.notes = notes; }

    public LocalDate getAppliedDate() { return appliedDate; }
    public void setAppliedDate(LocalDate appliedDate) {
        this.appliedDate = appliedDate;
    }

    public LocalDateTime getLastUpdated() { return lastUpdated; }
    public LocalDateTime getCreatedAt() { return createdAt; }
}

Notice the @ManyToOne annotation on the favoriteJob field. This tells JPA that many applications can point to one favorite job. The @JoinColumn annotation specifies which database column holds the foreign key. And @Enumerated(EnumType.STRING) tells JPA to store the enum value as a string in the database (e.g., "APPLIED") rather than as a number. Strings are far more readable when you query the database directly.

Now build the controller. The GET endpoint groups applications by status so the frontend can populate each column. The PUT endpoint updates a single application's status when a card is dragged:

package com.example.resumator.controller;

import com.example.resumator.model.ApplicationStatus;
import com.example.resumator.model.JobApplication;
import com.example.resumator.repository.JobApplicationRepository;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@RestController
@RequestMapping("/api/applications")
@CrossOrigin(origins = "*")
public class ApplicationController {

    private final JobApplicationRepository repository;

    public ApplicationController(JobApplicationRepository repository) {
        this.repository = repository;
    }

    @GetMapping
    public ResponseEntity<Map<String, List<JobApplication>>> getAllGrouped() {
        List<JobApplication> all = repository.findAll();

        Map<String, List<JobApplication>> grouped = all.stream()
            .collect(Collectors.groupingBy(
                app -> app.getStatus().name()
            ));

        // Ensure every status column exists in the response,
        // even if it has zero applications
        for (ApplicationStatus status : ApplicationStatus.values()) {
            grouped.putIfAbsent(status.name(), List.of());
        }

        return ResponseEntity.ok(grouped);
    }

    @PutMapping("/{id}/status")
    public ResponseEntity<?> updateStatus(
            @PathVariable Long id,
            @RequestBody Map<String, String> body) {

        String newStatus = body.get("status");
        if (newStatus == null) {
            return ResponseEntity.badRequest()
                .body(Map.of("error", "Missing 'status' in request body"));
        }

        ApplicationStatus status;
        try {
            status = ApplicationStatus.valueOf(newStatus);
        } catch (IllegalArgumentException e) {
            return ResponseEntity.badRequest()
                .body(Map.of("error", "Invalid status: " + newStatus));
        }

        return repository.findById(id).map(app -> {
            app.setStatus(status);
            repository.save(app);
            return ResponseEntity.ok(app);
        }).orElse(ResponseEntity.notFound().build());
    }
}

Let us look at the GET endpoint in detail. It fetches every application from the database, then uses Java Streams to group them by status. The Collectors.groupingBy method creates a Map where the key is the status name (a string like "APPLIED") and the value is a list of applications with that status. The loop at the end ensures that every status appears in the response, even if no applications have that status yet — this way the frontend always gets all six columns.

The PUT endpoint extracts the new status from the request body, validates it against the enum (catching invalid values with a try-catch), and then updates the application. The @PreUpdate lifecycle callback you defined in the entity automatically updates the lastUpdated timestamp.

Auto-creating applications when a job is favorited You will want to automatically create a JobApplication record whenever a user favorites a job. The cleanest way to do this is to modify your existing FavoriteController.saveFavorite() method: after saving the favorite, immediately create a new JobApplication with that favorite and a default status of INTERESTED. This way, every favorited job automatically appears on the Kanban board without the user doing anything extra. The board becomes the natural home for all saved jobs.

4. Drag and Drop with the HTML Drag API

This is the part that makes the Kanban board feel like a real application. Dragging a card from one column to another is one of the most satisfying interactions in UI design. And the best part? You do not need React. You do not need a library. You do not need any framework at all. The HTML5 Drag and Drop API is built right into every modern browser.

The drag and drop system works through a set of events. When you make an element draggable and attach event handlers, the browser manages the entire visual experience — the cursor changes, the element follows the mouse, and drop targets respond. You just need to tell the browser what should happen at each stage.

The Four Essential Drag Events

dragstart fires when the user starts dragging an element. This is where you store data about what is being dragged.
dragover fires continuously while a dragged element is over a valid drop target. You must call event.preventDefault() here, or the browser will not allow a drop.
drop fires when the user releases the dragged element over a valid target. This is where you handle the actual move.
dragend fires when the drag operation ends, regardless of whether a drop occurred. Use this for cleanup.

Here is a complete, working implementation. Study it carefully — every line serves a purpose:

// ============================================
// Kanban Board — Drag and Drop Handlers
// ============================================

// Store the ID of the card currently being dragged
let draggedCardId = null;

/**
 * Called when a user starts dragging a card.
 * Stores the application ID so the drop handler
 * knows which record to update.
 */
function handleDragStart(event) {
    draggedCardId = event.target.dataset.applicationId;
    event.target.classList.add('dragging');

    // Set the drag data (required for Firefox)
    event.dataTransfer.setData('text/plain', draggedCardId);
    event.dataTransfer.effectAllowed = 'move';
}

/**
 * Called continuously while a card hovers over a column.
 * We MUST call preventDefault() here — without it,
 * the browser will not allow a drop event to fire.
 */
function handleDragOver(event) {
    event.preventDefault();
    event.dataTransfer.dropEffect = 'move';

    // Add a visual highlight to the column
    const column = event.currentTarget;
    column.classList.add('drag-over');
}

/**
 * Called when the dragged card leaves a column.
 * Remove the visual highlight.
 */
function handleDragLeave(event) {
    event.currentTarget.classList.remove('drag-over');
}

/**
 * Called when the user drops a card onto a column.
 * This is where the real work happens — we update
 * the backend and move the card in the DOM.
 */
function handleDrop(event) {
    event.preventDefault();
    const column = event.currentTarget;
    column.classList.remove('drag-over');

    const newStatus = column.dataset.status;

    if (!draggedCardId || !newStatus) return;

    // Find the card element and move it into this column
    const card = document.querySelector(
        `[data-application-id="${draggedCardId}"]`
    );

    if (card) {
        const cardContainer = column.querySelector('.column-cards');
        cardContainer.appendChild(card);
        card.classList.remove('dragging');

        // Call the backend to persist the status change
        updateApplicationStatus(draggedCardId, newStatus);
    }

    draggedCardId = null;
}

/**
 * Called when any drag operation ends.
 * Clean up visual state regardless of outcome.
 */
function handleDragEnd(event) {
    event.target.classList.remove('dragging');
    // Remove drag-over from all columns
    document.querySelectorAll('.kanban-column').forEach(col => {
        col.classList.remove('drag-over');
    });
    draggedCardId = null;
}

/**
 * Send a PUT request to update the application status
 * on the backend. This persists the column change.
 */
async function updateApplicationStatus(applicationId, newStatus) {
    try {
        const response = await fetch(
            `/api/applications/${applicationId}/status`,
            {
                method: 'PUT',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ status: newStatus })
            }
        );

        if (!response.ok) {
            console.error('Failed to update status:', response.status);
            // In a production app, you would revert the card
            // position here and show an error message
        }
    } catch (error) {
        console.error('Network error updating status:', error);
    }
}

// ============================================
// Initialize the board
// ============================================

/**
 * Attach drag event listeners to all columns.
 * Call this after the board HTML is rendered.
 */
function initializeKanbanBoard() {
    // Make all cards draggable
    document.querySelectorAll('.kanban-card').forEach(card => {
        card.setAttribute('draggable', 'true');
        card.addEventListener('dragstart', handleDragStart);
        card.addEventListener('dragend', handleDragEnd);
    });

    // Make all columns accept drops
    document.querySelectorAll('.kanban-column').forEach(column => {
        column.addEventListener('dragover', handleDragOver);
        column.addEventListener('dragleave', handleDragLeave);
        column.addEventListener('drop', handleDrop);
    });
}

// Run initialization when the DOM is ready
document.addEventListener('DOMContentLoaded', initializeKanbanBoard);

console.log('Kanban drag-and-drop handlers loaded.');

There are several critical details in this code that are worth understanding deeply:

Why event.preventDefault() in dragover? This is the number one gotcha with HTML drag and drop. By default, the browser does not allow elements to be dropped on other elements. You have to explicitly opt in by calling preventDefault() on the dragover event. If you forget this single line, the drop event will never fire, and you will spend an hour wondering why your board does not work. Every developer who has used the Drag API has hit this at least once.

Why event.dataTransfer.setData()? Some browsers, particularly Firefox, require you to set data on the dataTransfer object for the drag operation to work at all. Even though we store the ID in a variable (draggedCardId), the setData call ensures cross-browser compatibility.

Optimistic UI update: Notice that the card is moved in the DOM before the API call completes. This is called an optimistic update — you assume the server call will succeed and update the UI immediately. This makes the board feel instant and responsive. If the API call fails, you would ideally revert the card position. For now, we log the error, but in a production application you would move the card back to its original column and show a notification.

No library needed You might see tutorials recommending libraries like SortableJS, react-beautiful-dnd, or dnd-kit for drag and drop. Those libraries are excellent and handle many edge cases. But for a Kanban board with column-based drops, the native HTML5 Drag API does everything you need. Building it yourself means you understand what is actually happening — and that understanding will make you far more effective when you eventually use a library in a professional project.

5. The Board UI

The JavaScript handles the behavior. Now you need the visual structure — the columns, the cards, and the layout that makes it all look like a proper Kanban board. The key design challenge is that the board needs to scroll horizontally. Six columns side by side will not fit on a phone screen (or even most laptop screens). Horizontal scrolling lets the user see as many columns as their screen allows and scroll to see the rest.

<!-- Kanban Board Container -->
<div class="kanban-board">

  <div class="kanban-column" data-status="INTERESTED">
    <div class="column-header">
      <span class="column-title">Interested</span>
      <span class="column-count">0</span>
    </div>
    <div class="column-cards">
      <!-- Cards inserted here dynamically -->
    </div>
  </div>

  <div class="kanban-column" data-status="APPLIED">
    <div class="column-header">
      <span class="column-title">Applied</span>
      <span class="column-count">0</span>
    </div>
    <div class="column-cards"></div>
  </div>

  <div class="kanban-column" data-status="PHONE_SCREEN">
    <div class="column-header">
      <span class="column-title">Phone Screen</span>
      <span class="column-count">0</span>
    </div>
    <div class="column-cards"></div>
  </div>

  <div class="kanban-column" data-status="INTERVIEW">
    <div class="column-header">
      <span class="column-title">Interview</span>
      <span class="column-count">0</span>
    </div>
    <div class="column-cards"></div>
  </div>

  <div class="kanban-column column-offer" data-status="OFFER">
    <div class="column-header">
      <span class="column-title">Offer</span>
      <span class="column-count">0</span>
    </div>
    <div class="column-cards"></div>
  </div>

  <div class="kanban-column column-rejected" data-status="REJECTED">
    <div class="column-header">
      <span class="column-title">Rejected</span>
      <span class="column-count">0</span>
    </div>
    <div class="column-cards"></div>
  </div>

</div>

<!-- Card Template (generated dynamically per application) -->
<!--
<div class="kanban-card" draggable="true"
     data-application-id="42">
  <div class="card-company">Google</div>
  <div class="card-title">Senior Java Developer</div>
  <div class="card-meta">
    <span>Mountain View, CA</span>
    <span>$120k - $180k</span>
  </div>
  <div class="card-date">Applied: Jan 15, 2026</div>
</div>
-->

Now the CSS that brings the board to life:

/* ============================================
   Kanban Board Layout
   ============================================ */

.kanban-board {
    display: flex;
    gap: 16px;
    padding: 20px;
    overflow-x: auto;          /* horizontal scroll */
    min-height: 70vh;
    align-items: flex-start;
}

.kanban-column {
    min-width: 280px;
    max-width: 320px;
    flex-shrink: 0;            /* prevent columns from compressing */
    background: var(--bg-secondary, #f4f5f7);
    border-radius: 8px;
    padding: 12px;
    display: flex;
    flex-direction: column;
    border-top: 3px solid var(--accent-blue, #2684ff);
    transition: background 0.2s ease;
}

/* Color coding for special columns */
.kanban-column.column-offer {
    border-top-color: #22c55e;       /* green for offers */
}

.kanban-column.column-rejected {
    border-top-color: #ef4444;       /* red for rejections */
}

/* Visual highlight when dragging over a column */
.kanban-column.drag-over {
    background: var(--bg-drag-over, #e2e8f0);
    outline: 2px dashed var(--accent-blue, #2684ff);
    outline-offset: -2px;
}

.column-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 8px 4px;
    margin-bottom: 12px;
}

.column-title {
    font-weight: 700;
    font-size: 0.9rem;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--text-primary, #1a1a1a);
}

.column-count {
    background: var(--bg-tertiary, #dfe1e6);
    color: var(--text-muted, #666);
    font-size: 0.75rem;
    font-weight: 600;
    padding: 2px 8px;
    border-radius: 12px;
}

.column-cards {
    display: flex;
    flex-direction: column;
    gap: 10px;
    min-height: 60px;          /* ensures empty columns are droppable */
    flex-grow: 1;
}

/* ============================================
   Kanban Card Design
   ============================================ */

.kanban-card {
    background: var(--bg-card, #ffffff);
    border-radius: 6px;
    padding: 14px;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
    cursor: grab;
    transition: box-shadow 0.2s ease, transform 0.15s ease;
    user-select: none;
}

.kanban-card:hover {
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
    transform: translateY(-1px);
}

.kanban-card.dragging {
    opacity: 0.5;
    transform: rotate(2deg);
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
    cursor: grabbing;
}

.card-company {
    font-size: 0.75rem;
    color: var(--text-muted, #888);
    text-transform: uppercase;
    letter-spacing: 0.04em;
    margin-bottom: 4px;
}

.card-title {
    font-weight: 600;
    font-size: 0.95rem;
    color: var(--text-primary, #1a1a1a);
    margin-bottom: 8px;
    line-height: 1.3;
}

.card-meta {
    display: flex;
    justify-content: space-between;
    font-size: 0.8rem;
    color: var(--text-muted, #888);
    margin-bottom: 6px;
}

.card-date {
    font-size: 0.75rem;
    color: var(--text-muted, #aaa);
}

/* ============================================
   Expanded Card View
   ============================================ */

.card-expanded-overlay {
    position: fixed;
    inset: 0;
    background: rgba(0, 0, 0, 0.5);
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 1000;
}

.card-expanded {
    background: var(--bg-card, #ffffff);
    border-radius: 12px;
    padding: 32px;
    max-width: 600px;
    width: 90%;
    max-height: 80vh;
    overflow-y: auto;
    box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}

.card-expanded h3 {
    margin-top: 0;
    font-size: 1.3rem;
}

.card-expanded .detail-row {
    display: flex;
    justify-content: space-between;
    padding: 8px 0;
    border-bottom: 1px solid var(--border-color, #eee);
    font-size: 0.9rem;
}

.card-expanded .detail-label {
    font-weight: 600;
    color: var(--text-muted, #666);
}

.card-expanded .notes-section textarea {
    width: 100%;
    min-height: 80px;
    border: 1px solid var(--border-color, #ddd);
    border-radius: 6px;
    padding: 10px;
    font-family: inherit;
    font-size: 0.9rem;
    resize: vertical;
}

A few design decisions worth highlighting:

Horizontal scrolling: The .kanban-board uses display: flex with overflow-x: auto. Each column has flex-shrink: 0 and a min-width, which means columns will never compress — they keep their size and the board scrolls horizontally when needed. This is the standard pattern for Kanban boards and works well on both desktop and mobile.

Color coding: The border-top on each column provides a subtle but effective color signal. Active columns (Interested through Interview) use the default blue. The Offer column uses green — the color of success. The Rejected column uses red. These colors are immediately intuitive and let you read the board's meaning at a glance.

Click to expand: When a user clicks a card, you show the expanded view as a modal overlay. The expanded view shows the full job details — company name, title, salary, location, job description — plus a notes textarea where the user can write reminders, and the application timeline (which you will build next). The overlay uses position: fixed with inset: 0 to cover the entire viewport.

Drag feedback: The .dragging class makes the card semi-transparent and slightly rotated — a visual cue that you are in drag mode. The .drag-over class on columns adds a dashed outline to show where you can drop. These small details make the interaction feel polished and professional.

6. Application Timeline

A status column tells you where an application is. A timeline tells you where it has been. When you are managing dozens of applications, being able to see the history of each one is invaluable. When did you apply? When did they respond? How long were you in the interview stage? This data helps you follow up at the right time and understand which companies move fast versus slow.

You need a new table to store timeline events. Each event records an application, a status, and a timestamp. Every time an application's status changes, you insert a new row:

CREATE TABLE application_timeline (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    application_id BIGINT NOT NULL,
    status VARCHAR(50) NOT NULL,
    changed_at DATETIME NOT NULL,
    note VARCHAR(500),

    CONSTRAINT fk_timeline_application
        FOREIGN KEY (application_id)
        REFERENCES job_applications(id)
        ON DELETE CASCADE
);

Now the JPA entity that maps to this table:

package com.example.resumator.model;

import jakarta.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "application_timeline")
public class ApplicationTimelineEvent {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "application_id", nullable = false)
    private JobApplication application;

    @Enumerated(EnumType.STRING)
    @Column(name = "status", nullable = false, length = 50)
    private ApplicationStatus status;

    @Column(name = "changed_at", nullable = false)
    private LocalDateTime changedAt;

    @Column(length = 500)
    private String note;

    @PrePersist
    protected void onCreate() {
        this.changedAt = LocalDateTime.now();
    }

    public ApplicationTimelineEvent() {}

    public ApplicationTimelineEvent(
            JobApplication application,
            ApplicationStatus status) {
        this.application = application;
        this.status = status;
    }

    public ApplicationTimelineEvent(
            JobApplication application,
            ApplicationStatus status,
            String note) {
        this.application = application;
        this.status = status;
        this.note = note;
    }

    // Getters
    public Long getId() { return id; }
    public JobApplication getApplication() { return application; }
    public ApplicationStatus getStatus() { return status; }
    public LocalDateTime getChangedAt() { return changedAt; }
    public String getNote() { return note; }
}

The key idea is that the ApplicationTimelineEvent is an immutable record. You never update a timeline event — you only insert new ones. This creates a complete, append-only history. The @ManyToOne relationship links each event to its parent application. The convenience constructors let you create events with or without a note in a single line of code.

Now update the status-change endpoint to automatically record a timeline event whenever an application moves to a new column:

// Inside ApplicationController — updated updateStatus method

@PutMapping("/{id}/status")
public ResponseEntity<?> updateStatus(
        @PathVariable Long id,
        @RequestBody Map<String, String> body) {

    String newStatus = body.get("status");
    if (newStatus == null) {
        return ResponseEntity.badRequest()
            .body(Map.of("error", "Missing 'status' in request body"));
    }

    ApplicationStatus status;
    try {
        status = ApplicationStatus.valueOf(newStatus);
    } catch (IllegalArgumentException e) {
        return ResponseEntity.badRequest()
            .body(Map.of("error", "Invalid status: " + newStatus));
    }

    return repository.findById(id).map(app -> {
        ApplicationStatus oldStatus = app.getStatus();
        app.setStatus(status);
        repository.save(app);

        // Record the status change in the timeline
        if (oldStatus != status) {
            ApplicationTimelineEvent event =
                new ApplicationTimelineEvent(app, status);
            timelineRepository.save(event);
        }

        return ResponseEntity.ok(app);
    }).orElse(ResponseEntity.notFound().build());
}

Notice the check if (oldStatus != status). This prevents creating a duplicate timeline entry if someone accidentally drops a card back into the same column. Only genuine status changes get recorded.

On the frontend, when a user clicks a card to expand it, you fetch the timeline for that application and display it as a vertical timeline:

/**
 * Fetch and render the timeline for a given application.
 * Displays a vertical list of status changes with timestamps.
 */
async function loadTimeline(applicationId, container) {
    const response = await fetch(
        `/api/applications/${applicationId}/timeline`
    );

    if (!response.ok) {
        container.innerHTML = '<p>Could not load timeline.</p>';
        return;
    }

    const events = await response.json();

    if (events.length === 0) {
        container.innerHTML = '<p class="timeline-empty">'
            + 'No status changes yet.</p>';
        return;
    }

    const html = events.map(event => `
        <div class="timeline-event">
            <div class="timeline-dot"></div>
            <div class="timeline-content">
                <span class="timeline-status">
                    ${formatStatus(event.status)}
                </span>
                <span class="timeline-date">
                    ${formatDate(event.changedAt)}
                </span>
                ${event.note
                    ? `<p class="timeline-note">${event.note}</p>`
                    : ''}
            </div>
        </div>
    `).join('');

    container.innerHTML = `
        <div class="timeline">${html}</div>
    `;
}

/** Convert STATUS_NAME to readable format */
function formatStatus(status) {
    return status.replace(/_/g, ' ').replace(/\b\w/g,
        c => c.toUpperCase()
    );
}

/** Format ISO datetime to a friendly string */
function formatDate(isoString) {
    const date = new Date(isoString);
    return date.toLocaleDateString('en-US', {
        month: 'short', day: 'numeric', year: 'numeric',
        hour: 'numeric', minute: '2-digit'
    });
}

The timeline reads like a story of your interaction with that company. "Interested on January 10. Applied on January 12. Phone Screen on January 20. Interview on February 3." When you have this data, follow-ups become natural: if you applied two weeks ago and the timeline shows no movement, it is time to send a polite check-in email.

Append-only data is powerful The timeline follows an append-only pattern — you only insert new events, never update or delete them. This is the same pattern used by bank transaction histories, Git commit logs, and audit trails. Append-only data is inherently trustworthy because it preserves the complete history. You can always answer "what happened and when?" without any ambiguity.

7. Stats Dashboard

You have a board. You have a timeline. Now add one more layer: numbers. A simple stats dashboard that sits above the board and shows you the big picture. When you are deep in the grind of applying to jobs, it is surprisingly easy to lose perspective. A stats dashboard gives you concrete data that replaces anxiety with clarity.

The stats you want are simple but meaningful:

You can compute all of these from the data you already have. The applications table gives you counts and statuses. The timeline table gives you timestamps for calculating averages. Here is a backend endpoint that aggregates the stats:

@GetMapping("/stats")
public ResponseEntity<Map<String, Object>> getStats() {
    List<JobApplication> all = repository.findAll();

    long total = all.size();
    long applied = all.stream()
        .filter(a -> a.getStatus() != ApplicationStatus.INTERESTED)
        .count();
    long interviews = all.stream()
        .filter(a -> a.getStatus() == ApplicationStatus.INTERVIEW
            || a.getStatus() == ApplicationStatus.OFFER)
        .count();
    long offers = all.stream()
        .filter(a -> a.getStatus() == ApplicationStatus.OFFER)
        .count();
    long rejected = all.stream()
        .filter(a -> a.getStatus() == ApplicationStatus.REJECTED)
        .count();

    double responseRate = applied > 0
        ? ((double)(applied - rejected) / applied) * 100.0
        : 0.0;

    Map<String, Object> stats = new java.util.LinkedHashMap<>();
    stats.put("totalTracked", total);
    stats.put("totalApplied", applied);
    stats.put("interviews", interviews);
    stats.put("offers", offers);
    stats.put("rejected", rejected);
    stats.put("responseRate", Math.round(responseRate * 10.0) / 10.0);

    return ResponseEntity.ok(stats);
}

On the frontend, display these stats as a row of cards above the Kanban board:

/**
 * Fetch stats from the backend and render them
 * as a dashboard above the Kanban board.
 */
async function loadStats() {
    const response = await fetch('/api/applications/stats');
    if (!response.ok) return;

    const stats = await response.json();

    const dashboard = document.getElementById('stats-dashboard');
    dashboard.innerHTML = `
        <div class="stat-card">
            <div class="stat-number">${stats.totalTracked}</div>
            <div class="stat-label">Total Tracked</div>
        </div>
        <div class="stat-card">
            <div class="stat-number">${stats.totalApplied}</div>
            <div class="stat-label">Applied</div>
        </div>
        <div class="stat-card">
            <div class="stat-number">${stats.interviews}</div>
            <div class="stat-label">Interviews</div>
        </div>
        <div class="stat-card stat-card-green">
            <div class="stat-number">${stats.offers}</div>
            <div class="stat-label">Offers</div>
        </div>
        <div class="stat-card">
            <div class="stat-number">${stats.responseRate}%</div>
            <div class="stat-label">Response Rate</div>
        </div>
    `;
}

// Load stats when the page loads
document.addEventListener('DOMContentLoaded', loadStats);
Why stats matter for your mental health Job searching is one of the most emotionally draining experiences in a professional career. Rejections pile up and it is easy to feel like nothing is working. But concrete numbers tell a different story. When you can see that you have applied to 35 jobs, gotten 5 phone screens, and landed 3 interviews, you realize the process is working — it is just a numbers game. The stats dashboard turns abstract anxiety into actionable data. It might sound like a small thing, but for many developers, tracking their applications this way is what kept them going until they landed their first job.

Think about how far you have come in this side quest. You started with a concept — a Kanban board — and built every layer yourself. A database table with foreign keys. Backend endpoints that group data and handle status transitions. Native drag and drop with no libraries. A timeline that tracks history. A stats dashboard that aggregates everything into meaningful numbers. This is a full-stack feature. It touches the database, the backend, the API, the DOM, the CSS, and the user experience. And you built it all.

Test Your Knowledge

1. In the HTML5 Drag and Drop API, why is it necessary to call event.preventDefault() inside the dragover event handler?

Correct! The browser's default behavior is to reject drops on elements. By calling event.preventDefault() on the dragover event, you override this default and tell the browser that the element is a valid drop target. Without this call, the drop event will simply never fire, no matter what other code you write. This is the single most common mistake developers make when first working with the HTML5 Drag and Drop API.

2. Why does the job_applications table use a foreign key to favorite_jobs instead of storing all the job details directly?

That's right! This is database normalization in practice. Instead of copying the job title, company name, salary, and every other field into the job_applications table, you store a single favorite_job_id that points to the original record. If the job details ever need to be updated, you update them in one place. This eliminates data duplication, reduces storage, and prevents inconsistencies where the same job has different information in different tables.

3. What is an "optimistic UI update" in the context of the drag-and-drop Kanban board?

Correct! An optimistic UI update means you update the interface immediately without waiting for the server to respond. You "optimistically" assume the request will succeed, which makes the app feel instant and responsive. If the server call fails, you revert the change and notify the user. This pattern is used extensively in modern web applications — Google Docs, Trello, and GitHub all use optimistic updates to keep the UI feeling fast even when network requests take time.

Deliverable

When you complete this side quest, you should have a working Kanban board integrated into your Resumator application with the following features:

This is a portfolio-worthy feature. A Kanban board demonstrates that you can build interactive, data-driven interfaces, manage relational data across multiple tables, and implement a real workflow system. When an interviewer asks "What have you built?", this is the kind of feature that starts a great conversation.

Finished this side quest?

← Back to Side Quests