Sitemap

Building a Dynamic Shipment Timeline with Spring Boot & Angular

12 min readNov 11, 2025

--

We built a backend and frontend that derives a shipment’s progress from its history and classifies its overall status (e.g., GOOD, WARNING, DANGER, or INITIAL). This classification uses configurable parameters based on locations or specific conditions.

Live changes are pushed to the browser via Server-Sent Events (SSE) and API endpoints, with scheduled jobs managing the updates. We also built a reusable Angular component that colors the timeline based on its status category.

For this tutorial, we use a sample dataset of 10 shipments spanning common scenarios. This allows us to focus on the timeline, its mapping, and a small scheduler that simulates live updates.

Timelines transform raw data into signals. A well-designed timeline instantly answers three key questions:

  • Progress: What’s done and what’s next?
  • Evidence: When did each step happen?
  • Health: Are we on track, borderline, or late?

Color-coding the entire timeline (e.g., GOOD, WARNING, DANGER) reduces cognitive load, prevents support tickets, and builds trust. This pattern applies to any multi-step process, from orders and approvals to KYC flows.

Press enter or click to view image in full size

Architecture Overview

In a production environment, large systems ingest shipment data from carriers via various methods like FTP, SFTP, or JSON APIs. This data is then linked with internal records to create or update shipments and their histories in the database. Configuration parameters, which are often based on carrier agreements.

Get Sulaiman Mahyoub’s stories in your inbox

Join Medium for free to get updates from this writer.

For this tutorial, we simplify this process. The configurable rules — such as thresholds for nearby cities and allowed transit days — are defined in the application.yml file.

timeline:
outbound:
nearbyCities: [ Casablanca, Mohammedia ]
nearbyDays: 1
otherDays: 3
warningDays: 2
thresholdDays: 5
returns:
normalDays: 3
warningDays: 5
thresholdDays: 7

spring:
application:
name: shipment-timeline-backend
jpa:
hibernate:
ddl-auto: create-drop
show-sql: 'true'
open-in-view: 'false'
datasource:
url: jdbc:postgresql://localhost:5432/your-own-db
username: postgres
password: your-own-password

Data model and Repositories

// ShipmentType.java
public enum ShipmentType { OUTBOUND, RETURN }

// Status.java
public enum Status {
CREATED, RECEIVED, DEPARTED, ARRIVED, IN_PROGRESS, OUT_FOR_DELIVERY, DELIVERED, RETURNED
}

// Shipment.java
@Entity @Table(name = "shipments")
public class Shipment {
@Id @GeneratedValue private Long id;

@Column(unique = true, nullable = false)
private String awb;

@Enumerated(EnumType.STRING)
private ShipmentType type;

private String destinationCity;
private LocalDateTime originReceivedDate;

@OneToMany(mappedBy = "shipment", cascade = CascadeType.ALL,
orphanRemoval = true, fetch = FetchType.LAZY)
private Set<ShipmentHistory> histories = new HashSet<>();

// getters/setters...
}

// ShipmentHistory.java
@Entity @Table(name = "shipment_histories")
public class ShipmentHistory {
@Id @GeneratedValue private Long id;

@ManyToOne(optional = false) @JoinColumn(name = "shipment_id")
private Shipment shipment;

@Enumerated(EnumType.STRING)
private Status label;

private LocalDateTime statusDate;
// getters/setters...
}

/*
We chose JPA for its simplicity, as the application does not
require complex queries. To efficiently load associated data and prevent
LazyInitializationException, we use fetch joins.
*/

// ShipmentRepo.java
public interface ShipmentRepo extends JpaRepository<Shipment, Long> {

@Query("SELECT s FROM Shipment s LEFT JOIN FETCH s.histories")
List<Shipment> findAllWithHistories();

@Query("SELECT s FROM Shipment s LEFT JOIN FETCH s.histories WHERE s.id = ?1")
Optional<Shipment> findByIdWithHistories(Long id);
}

public interface ShipmentHistoryRepo extends JpaRepository<ShipmentHistory, Long> {
}

This implementation uses static nested classes to group related properties that serve a single, specific purpose. This approach enables type-safe configuration binding through the @ConfigurationProperties scan, which provides a more robust and maintainable solution compared to individual @Value annotations.


@Configuration
@ConfigurationProperties(prefix = "timeline")
public class TimelineProperties {
private Outbound outbound = new Outbound();
private Returns returns = new Returns();

public static class Outbound {
private List<String> nearbyCities = List.of();
private int nearbyDays = 1, otherDays = 3, warningDays = 2, thresholdDays = 5;

// getters/setters
}

public static class Returns {
private int normalDays = 3, warningDays = 5, thresholdDays = 7;

// getters/setters
}

// getters/setters
}

Dto


public class ShipmentTimelineDto {

private String awb;
private ShipmentType type;
private String destinationCity;
private LocalDateTime originReceivedDate;
private List<Item> items = new ArrayList<>();

/**
* The overall status category of the shipment timeline.
* Possible values: GOOD, WARNING, DANGER, INITIAL
* Determines the visual color coding in the UI.
*/
private String category;

/**
* Total days between shipment start and completion (or current time if not completed).
* Used for progress tracking and status classification.
*/
private int daysDifference;

// getters/setters

/**
* Represents a single milestone in the shipment timeline.
* Static nested class as it's only used within ShipmentTimelineDto context.
*/
public static class Item {
/**
* The status name (e.g., "RECEIVED", "IN_TRANSIT", "DELIVERED")
*/
private String status;

/**
* When this status event occurred
*/
private LocalDateTime statusDate;

/**
* Indicates whether this milestone has been completed.
* Used to distinguish achieved vs. pending steps in the timeline.
*/
private boolean achieved;

// getters/setters
}
}

TimelineService: where the logic lives

  • Steps are type-aware (RETURN flows end at RETURNED, OUTBOUND at DELIVERED).
  • Start date = first RECEIVED (fallback: earliest history, then originReceivedDate).
  • End date = first terminal (type-appropriate), or now if still in progress.
  • Category uses your the settings thresholds in the yml file.
import io.github.shiptimeline.shipment_timeline_backend.api.dto.ShipmentTimelineDto;
import io.github.shiptimeline.shipment_timeline_backend.config.TimelineProperties;
import io.github.shiptimeline.shipment_timeline_backend.domain.Shipment;
import io.github.shiptimeline.shipment_timeline_backend.domain.ShipmentHistory;
import io.github.shiptimeline.shipment_timeline_backend.domain.ShipmentType;
import io.github.shiptimeline.shipment_timeline_backend.domain.Status;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;

@Service
public class TimelineService {

/**
* Injected configuration properties containing timeline rules
* (thresholds, nearby cities, etc.) for status classification.
*/
private final TimelineProperties props;

public TimelineService(TimelineProperties props) {
this.props = props;
}

/**
* Constructs a ShipmentTimelineDto from a Shipment entity for frontend consumption.
* The process involves three main steps:
* 1. Building timeline items based on shipment type
* 2. Calculating duration between start and end dates
* 3. Classifying the overall status category
*/
public ShipmentTimelineDto build(Shipment s) {
var dto = new ShipmentTimelineDto();
dto.setId(s.getId());
dto.setAwb(s.getAwb());
dto.setType(s.getType());
dto.setDestinationCity(s.getDestinationCity());
dto.setOriginReceivedDate(s.getOriginReceivedDate());

// 1) Build timeline items based on shipment type
for (var step : stepsFor(s.getType())) {
dto.getItems().add(toItem(step, s.getHistories()));
}

// 2) Calculate timeline duration
var start = first(Status.RECEIVED, s.getHistories())
.orElseGet(() -> earliest(s.getHistories()).orElse(s.getOriginReceivedDate()));

var end = firstTerminalOfType(s.getHistories(), s.getType()).orElse(null);
int days = (start == null) ? 0
: (int) Duration.between(start, end != null ? end : LocalDateTime.now()).toDays();
dto.setDaysDifference(days);

// 3) Determine status category based on business rules
dto.setCategory(classify(s, days));
return dto;
}

/**
* Returns the sequence of status steps for a given shipment type.
* Note: Outbound and Return shipments have different terminal statuses
* (DELIVERED vs RETURNED), allowing for easy extension with additional types.
*/
private List<Status> stepsFor(ShipmentType type) {
return (type == ShipmentType.RETURN)
? List.of(Status.CREATED, Status.RECEIVED, Status.DEPARTED, Status.IN_PROGRESS,
Status.ARRIVED, Status.OUT_FOR_DELIVERY, Status.RETURNED)
: List.of(Status.CREATED, Status.RECEIVED, Status.DEPARTED, Status.ARRIVED,
Status.IN_PROGRESS, Status.OUT_FOR_DELIVERY, Status.DELIVERED);
}

/**
* Finds the first terminal status date for a shipment type.
* Terminal statuses differ between Outbound (DELIVERED) and Return (RETURNED) shipments.
*/
private Optional<LocalDateTime> firstTerminalOfType(Set<ShipmentHistory> hs, ShipmentType type) {
Set<Status> terminals = (type == ShipmentType.RETURN)
? Set.of(Status.RETURNED) : Set.of(Status.DELIVERED);
return hs.stream()
.filter(h -> terminals.contains(h.getLabel()))
.map(ShipmentHistory::getStatusDate)
.filter(Objects::nonNull)
.min(LocalDateTime::compareTo);
}

/**
* Converts a Status to a timeline Item by finding its earliest occurrence in history.
* Sets the achieved flag based on whether the status date exists.
*/
private ShipmentTimelineDto.Item toItem(Status step, Set<ShipmentHistory> hs) {
var it = new ShipmentTimelineDto.Item();
it.setStatus(step.name());
hs.stream().filter(h -> h.getLabel() == step)
.map(ShipmentHistory::getStatusDate)
.filter(Objects::nonNull)
.min(LocalDateTime::compareTo)
.ifPresent(it::setStatusDate);
it.setAchieved(it.getStatusDate() != null);
return it;
}

/**
* Finds the earliest date in shipment history using Java Streams and Optional.
* Demonstrates use of built-in Java solutions over third-party dependencies.
*/
private Optional<LocalDateTime> earliest(Set<ShipmentHistory> hs) {
return hs.stream().map(ShipmentHistory::getStatusDate)
.filter(Objects::nonNull).min(LocalDateTime::compareTo);
}

/**
* Finds the first occurrence of a specific status in shipment history.
* Uses immutable collections (List.of, Set.of) for thread-safe operations.
*/
private Optional<LocalDateTime> first(Status s, Set<ShipmentHistory> hs) {
return hs.stream().filter(h -> h.getLabel() == s)
.map(ShipmentHistory::getStatusDate)
.filter(Objects::nonNull).min(LocalDateTime::compareTo);
}

/**
* Classifies shipment status based on configured thresholds and business rules:
* - OUTBOUND: Differentiates between nearby and other cities with separate thresholds
* - RETURN: Uses generic thresholds regardless of destination
* Returns: GOOD, WARNING, DANGER, or INITIAL based on actual vs expected duration
*/
private String classify(Shipment s, int days) {
if (s.getType() == ShipmentType.OUTBOUND) {
boolean nearby = Optional.ofNullable(s.getDestinationCity()).map(dc ->
props.getOutbound().getNearbyCities().stream().anyMatch(n -> dc.toLowerCase().contains(n.toLowerCase()))
).orElse(false);

int allow = nearby ? props.getOutbound().getNearbyDays() : props.getOutbound().getOtherDays();
if (days <= allow) return "GOOD";
if (days <= allow + props.getOutbound().getWarningDays()) return "WARNING";
if (days > allow + props.getOutbound().getThresholdDays()) return "DANGER";
return "WARNING";
}
if (days <= props.getReturns().getNormalDays()) return "GOOD";
if (days <= props.getReturns().getWarningDays()) return "WARNING";
if (days > props.getReturns().getThresholdDays()) return "DANGER";
return "INITIAL";
}
}

Service + Controller

@Service
public class ShipmentService {
private final ShipmentRepo shipments;
private final TimelineService timeline;

public ShipmentService(ShipmentRepo shipments, TimelineService timeline) {
this.shipments = shipments; this.timeline = timeline;
}

@Transactional(readOnly = true)
public List<ShipmentTimelineDto> findAll() {
return shipments.findAllWithHistories().stream()
.map(timeline::build).toList();
}

@Transactional(readOnly = true)
public Shipment findOne(Long id) {
return shipments.findByIdWithHistories(id).orElseThrow();
}
}
@RestController
@RequestMapping("/api/shipments")
public class ShipmentController {
private final ShipmentService srv; private final TimelineService timeline;

public ShipmentController(ShipmentService srv, TimelineService timeline) {
this.srv = srv; this.timeline = timeline;
}

@GetMapping public List<ShipmentTimelineDto> list() { return srv.findAll(); }

@GetMapping("/{id}/timeline")
public ShipmentTimelineDto one(@PathVariable Long id) {
return timeline.build(srv.findOne(id));
}
}

Live updates with SSE (no polling)

@Component
public class TimelineSseHub {
// shipmentId -> subscribers
private final Map<Long, CopyOnWriteArrayList<SseEmitter>> clients = new ConcurrentHashMap<>();

public SseEmitter subscribe(long shipmentId) {
SseEmitter emitter = new SseEmitter(0L); // no timeout
clients.computeIfAbsent(shipmentId, id -> new CopyOnWriteArrayList<>()).add(emitter);

Runnable remove = () -> clients.getOrDefault(shipmentId, new CopyOnWriteArrayList<>()).remove(emitter);
emitter.onCompletion(remove); emitter.onTimeout(remove); emitter.onError(e -> remove.run());
return emitter;
}

public void publish(long shipmentId, ShipmentTimelineDto dto) {
var list = clients.get(shipmentId);
if (list == null) return;
for (var emitter : list) {
try {
emitter.send(SseEmitter.event().name("timeline").data(dto));
} catch (IOException e) {
emitter.completeWithError(e);
}
}
}

// keep-alive for proxies
@Scheduled(fixedRate = 15000)
public void heartbeat() {
clients.values().forEach(ls -> ls.forEach(em -> {
try { em.send(SseEmitter.event().comment("💓")); } catch (IOException e) { em.complete(); }
}));
}
}

Demo data + scheduler (10 shipments, live progress)

@Component
public class DemoData implements CommandLineRunner {
private final ShipmentRepo shipments;
private final ShipmentHistoryRepo histories;

public DemoData(ShipmentRepo shipments, ShipmentHistoryRepo histories) {
this.shipments = shipments;
this.histories = histories;
}

/**
* Executes on application startup to populate the database with sample shipment data.
* Creates 10 shipments spanning various scenarios to demonstrate timeline functionality:
* - Different status categories (GOOD, WARNING, DANGER, INITIAL)
* - Both OUTBOUND and RETURN shipment types
* - Various cities and timeframes for testing configuration rules
* - Mixed completion states (in-progress, delivered, returned)
*/
@Override
public void run(String... args) {
LocalDateTime now = LocalDateTime.now();

// INITIAL status examples - minimal progress
Shipment s1 = base("S-001", ShipmentType.OUTBOUND, "Casablanca", now);
add(s1, Status.CREATED, now.minusHours(2));
add(s1, Status.RECEIVED, now.minusHours(1));

Shipment s2 = base("S-002", ShipmentType.OUTBOUND, "Rabat", now.minusDays(2));
add(s2, Status.CREATED, now.minusDays(2).minusHours(2));
add(s2, Status.RECEIVED, now.minusDays(2));

// WARNING/GOOD status examples - moderate progress
Shipment s3 = base("S-003", ShipmentType.OUTBOUND, "Marrakech", now.minusDays(7));
add(s3, Status.CREATED, now.minusDays(7).minusHours(1));
add(s3, Status.RECEIVED, now.minusDays(7));
add(s3, Status.DEPARTED, now.minusDays(6));

Shipment s4 = base("S-004", ShipmentType.OUTBOUND, "Tangier", now.minusDays(2));
add(s4, Status.CREATED, now.minusDays(2).minusHours(3));
add(s4, Status.RECEIVED, now.minusDays(2));

Shipment s5 = base("S-005", ShipmentType.OUTBOUND, "Agadir", now.minusDays(4));
add(s5, Status.CREATED, now.minusDays(4).minusHours(3));
add(s5, Status.RECEIVED, now.minusDays(4));
add(s5, Status.ARRIVED, now.minusDays(3));

// COMPLETED examples - terminal status reached
Shipment s6 = base("S-006", ShipmentType.OUTBOUND, "Fez", now.minusDays(3));
add(s6, Status.CREATED, now.minusDays(3).minusHours(2));
add(s6, Status.RECEIVED, now.minusDays(3));
add(s6, Status.OUT_FOR_DELIVERY, now.minusDays(1));
add(s6, Status.DELIVERED, now); // terminal status for OUTBOUND

// RETURN shipment examples
Shipment s7 = base("S-007", ShipmentType.RETURN, "Tangier", now.minusDays(2));
add(s7, Status.CREATED, now.minusDays(2).minusHours(2));
add(s7, Status.RECEIVED, now.minusDays(2));

Shipment s8 = base("S-008", ShipmentType.RETURN, "Casablanca", now.minusDays(4));
add(s8, Status.CREATED, now.minusDays(4).minusHours(1));
add(s8, Status.RECEIVED, now.minusDays(4));
add(s8, Status.IN_PROGRESS, now.minusDays(3));

// DANGER status example - significantly delayed
Shipment s9 = base("S-009", ShipmentType.RETURN, "Agadir", now.minusDays(8));
add(s9, Status.CREATED, now.minusDays(8).minusHours(2));
add(s9, Status.RECEIVED, now.minusDays(8));
add(s9, Status.IN_PROGRESS, now.minusDays(7));

// COMPLETED RETURN example
Shipment s10 = base("S-010", ShipmentType.RETURN, "Rabat", now.minusDays(3));
add(s10, Status.CREATED, now.minusDays(3).minusHours(2));
add(s10, Status.RECEIVED, now.minusDays(3));
add(s10, Status.RETURNED, now.minusDays(1)); // terminal status for RETURN
}

/**
* Creates and persists a base Shipment entity with common attributes.
*
* @param awb the Air Waybill number (unique identifier)
* @param type shipment type (OUTBOUND or RETURN)
* @param city destination city for classification rules
* @param receivedAt origin receipt timestamp
* @return the persisted Shipment entity
*/
private Shipment base(String awb, ShipmentType type, String city, LocalDateTime receivedAt) {
Shipment s = new Shipment();
s.setAwb(awb);
s.setType(type);
s.setDestinationCity(city);
s.setOriginReceivedDate(receivedAt);
return shipments.save(s);
}

/**
* Creates and adds a status history entry to a shipment.
* Maintains bidirectional relationship and persists both entities.
*
* @param s the target shipment
* @param label the status to add (e.g., CREATED, RECEIVED, DELIVERED)
* @param at when this status event occurred
*/
private void add(Shipment s, Status label, LocalDateTime at) {
ShipmentHistory h = new ShipmentHistory();
h.setShipment(s);
h.setLabel(label);
h.setStatusDate(at);
histories.save(h);
s.getHistories().add(h);
shipments.save(s);
}
}
@Component
public class DemoScheduler {
private final ShipmentRepo shipments; private final ShipmentHistoryRepo histories;
private final TimelineService timeline; private final TimelineSseHub hub;

private static final List<Status> OUTBOUND = List.of(
Status.CREATED, Status.RECEIVED, Status.DEPARTED, Status.ARRIVED,
Status.IN_PROGRESS, Status.OUT_FOR_DELIVERY, Status.DELIVERED
);
private static final List<Status> RETURN = List.of(
Status.CREATED, Status.RECEIVED, Status.IN_PROGRESS, Status.RETURNED
);

public DemoScheduler(ShipmentRepo shipments, ShipmentHistoryRepo histories,
TimelineService timeline, TimelineSseHub hub) {
this.shipments = shipments; this.histories = histories; this.timeline = timeline; this.hub = hub;
}

@Scheduled(fixedRate = 8000) @Transactional
public void advanceSome() {
var all = shipments.findAllWithHistories();
Collections.shuffle(all);
all.stream().limit(5).forEach(s -> {
if (isTerminated(s)) return;
var next = nextStatus(s); if (next == null) return;
var h = new ShipmentHistory();
h.setShipment(s); h.setLabel(next); h.setStatusDate(LocalDateTime.now());
histories.save(h); s.getHistories().add(h); shipments.save(s);
hub.publish(s.getId(), timeline.build(s)); // push to clients
});
}

private boolean isTerminated(Shipment s){
return s.getHistories().stream().anyMatch(h ->
h.getLabel()==Status.DELIVERED || h.getLabel()==Status.RETURNED);
}
private Status nextStatus(Shipment s){
var path = (s.getType()==ShipmentType.RETURN)? RETURN : OUTBOUND;
var achieved = s.getHistories().stream().map(ShipmentHistory::getLabel).toList();
for (var st: path) if (!achieved.contains(st)) return st;
return null;
}
}
/**
* Main Spring Boot application class for the Shipment Timeline Backend.
*
* <p>This application provides a backend service for tracking shipment progress
* with real-time timeline updates and status classification (GOOD, WARNING, DANGER, INITIAL).
* It supports both outbound and return shipments with configurable business rules.</p>
*/
@SpringBootApplication
@EnableScheduling
public class ShipmentTimelineBackendApplication {

/**
* Application entry point that starts the Spring Boot context.
*
* @param args command line arguments passed during application startup
*/
public static void main(String[] args) {
SpringApplication.run(ShipmentTimelineBackendApplication.class, args);
}

/**
* Configures CORS (Cross-Origin Resource Sharing) for frontend communication.
*
* <p>This configuration allows the Angular frontend application running on localhost:4200
* to access shipment API endpoints while restricting access to specific HTTP methods
* for security purposes.</p>
*
* @return WebMvcConfigurer with CORS settings tailored for frontend-backend integration
*/
@Bean
public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
// Configure CORS for all shipment API endpoints
registry.addMapping("/api/shipments/**")
// Allow requests from Angular development server
.allowedOrigins("http://localhost:4200")
// Restrict to read-only operations for security
.allowedMethods("GET")
// Allow all headers for flexibility in API communication
.allowedHeaders("*")
// Support authentication cookies if required
.allowCredentials(true);
}
};
}
}

Frontend

in the front section, we will just focus on the timeline and the others code will be in the GitHub repo

so first we do the interfaces models as backend but in ts

export type Category = 'GOOD' | 'WARNING' | 'DANGER' | 'INITIAL';


export interface ShipmentTimelineDto {
shipmentId?: string;
shipmentName?: string;
shipmentType?: string;
awb?: string;
category: Category;
items: TimelineItem[];
daysDifference: number;
}

export interface TimelineItem {
status: string;
statusDate: string ;
achieved: boolean;
}t

@Component({
selector: 'app-shipment-timeline',
imports: [NgIf, NgFor, NgStyle],
templateUrl: './shipment-timeline.component.html',
standalone: true,
styleUrls: ['./shipment-timeline.component.scss']
})
export class ShipmentTimelineComponent implements OnChanges {
@Input() data!: ShipmentTimelineDto | null;
lastAchievedIndex = -1;

ngOnChanges(_: SimpleChanges) {
this.lastAchievedIndex = this.data ? this.data.items.reduce((acc, it, i) => it.achieved ? i : acc, -1) : -1;
}

color(c: Category) {
return ({
GOOD: 'var(--good)',
WARNING: 'var(--warn)',
DANGER: 'var(--danger)',
INITIAL: 'var(--initial)'
} as any)[c];
}

colorL(c: Category) {
return ({
GOOD: 'var(--goodL)',
WARNING: 'var(--warnL)',
DANGER: 'var(--dangerL)',
INITIAL: 'var(--initialL)'
} as any)[c];
}

circleStyle(i: number) {
if (!this.data) return {};
const on = i <= this.lastAchievedIndex;
const cat = this.data.category as Category;
return {
'background-color': on ? this.color(cat) : 'var(--initial)',
'border-color': on ? this.colorL(cat) : 'var(--initialL)'
};
}

lineStyle(i: number) {
if (!this.data) return {};
const on = i <= this.lastAchievedIndex;
const cat = this.data.category as Category;
return {
'background-color': on ? this.color(cat) : 'var(--initial)',
'border-color': on ? this.colorL(cat) : 'var(--initialL)'
};
}

formatDate(d: string | null) {
if (!d) return '00/00/0000 T 00:00';
const x = new Date(d);
const mm = String(x.getMonth() + 1).padStart(2, '0');
const dd = String(x.getDate()).padStart(2, '0');
const yyyy = x.getFullYear();
const hh = String(x.getHours()).padStart(2, '0');
const mi = String(x.getMinutes()).padStart(2, '0');
return `${mm}/${dd}/${yyyy} T ${hh}:${mi}`;
}

}
<div class="card">
<div *ngIf="data" class="timeline" role="list" aria-label="Shipment timeline">
<div *ngFor="let it of data.items; let i = index" class="timeline-item" role="listitem">
<div *ngIf="i>0" class="timeline-line" [ngStyle]="lineStyle(i)"></div>
<div class="circle" [ngStyle]="circleStyle(i)" aria-hidden="true"></div>
<div class="date">{{ formatDate(it.statusDate) }}</div>
<div class="status">{{ it.status }}</div>
</div>
</div>
</div>

This Angular timeline component dynamically styles shipment progress using ngStyle directives and the OnChanges lifecycle hook. It applies status-based color coding (GOOD/WARNING/DANGER/INITIAL) through CSS variables, creating clear visual progression as milestones are achieved. The component efficiently tracks progress using lastAchievedIndex and features accessible markup with ARIA roles. This approach ensures maintainable, performant, and reusable timeline visualization that responds instantly to data changes.

The full example — backend services, the Angular timeline component, and a live SSE stream — is available here:

Backend (Spring Boot):

https://github.com/sulaimanmahyoub462/timeline-backend

  • Frontend (Angular):

https://github.com/sulaimanmahyoub462/timeline-front

If you find it useful, a ⭐ helps others discover it. I’m always open to feedback and collaboration on building clear, reliable tracking UX — feel free to reach out.

--

--

Sulaiman Mahyoub
Sulaiman Mahyoub

Written by Sulaiman Mahyoub

Sulaiman Mahyoub Full-stack developer with 4+ years in Java, Spring, Angular. Java 17 certified , geospatial expert, passionate about innovative web solutions.