Feature: Price Tracking & History
Relevant source files
The following files were used as context for generating this wiki page:
The Price Tracking and History feature is a cornerstone of the PriceBuddy application. It enables users to monitor the price of a single product across multiple online retailers. The system periodically fetches prices from user-provided URLs, stores this data chronologically, and provides visualizations to help users identify price trends and historical lows.
This feature is composed of several key components: data models for products, URLs, and prices; a price fetching mechanism that scrapes web pages; a caching layer to optimize performance; and a user interface for displaying price history charts and statistics. The entire process is designed to be automated, with daily cron jobs ensuring that price data remains up-to-date.
Data Models
The price tracking system is built upon a set of interconnected Eloquent models that store product information, associated URLs, and their price history.
Entity Relationship Diagram
The following diagram illustrates the relationships between the core models involved in price tracking.
Product Model
The Product model is the central entity. It represents a unique item being tracked and aggregates data from multiple URLs.
| Field | Type | Description |
|---|---|---|
id | int | Primary key. |
user_id | int | Foreign key linking to the users table. |
title | string | The name of the product. |
image | string | URL to the product's primary image. |
current_price | float | The current lowest price across all tracked URLs for this product. |
price_cache | array (JSON) | A cached, denormalized array of price history and metadata for each URL to speed up rendering. |
status | Statuses (Enum) | The status of the product (e.g., Published, Archived). |
favourite | boolean | Indicates if the product is marked as a favorite. |
notify_price | float | The price threshold at or below which a notification should be sent. |
notify_percent | float | The percentage drop from the initial price that should trigger a notification. |
Sources: app/Models/Product.php
Url Model
Each Product can have multiple Url records, representing the same product on different retail websites.
| Field | Type | Description |
|---|---|---|
id | int | Primary key. |
product_id | int | Foreign key linking to the products table. |
store_id | int | Foreign key linking to the stores table. |
url | string | The raw URL of the product page. |
Sources: app/Models/Url.php
Price Model
The Price model stores a single price point for a specific URL at a specific point in time.
| Field | Type | Description |
|---|---|---|
id | int | Primary key. |
url_id | int | Foreign key linking to the urls table. |
store_id | int | Foreign key linking to the stores table. |
price | float | The price recorded at a point in time. |
created_at | datetime | The timestamp when the price was recorded. |
Sources: app/Models/Price.php (Implicit from relationships)
Price Fetching and Caching
The system fetches prices from product URLs and caches the history to ensure the UI remains performant.
Price Update Flow
The Product::updatePrices() method initiates the process. It iterates through all associated Url models and calls updatePrice() on each. After all URLs are processed, it updates the product's price_cache.
This diagram shows the sequence of operations when updating prices for a product.
Sources: app/Models/Product.php:436-444, app/Models/Url.php:149-167
Price History Caching
To avoid performance bottlenecks from querying the prices table on every page load, the Product model uses a price_cache JSON column. This cache is built by the Product::buildPriceCache() method.
The caching process involves:
- Fetching all historical prices for the product's URLs for the past year using
getPriceHistory(). - Grouping prices by URL and then by date to find the minimum price for each day.
- For each URL, compiling an object containing store details, the current price, and a history of daily prices.
- Sorting the final collection by price and storing it as a JSON array in the
price_cachecolumn of theproductstable.
The current_price on the Product is also updated to the lowest price found in the cache.
Sources: app/Models/Product.php:382-401, app/Models/Product.php:449-453
price_cache Structure
The price_cache is an array of objects, where each object represents a tracked URL.
| Key | Type | Description |
|---|---|---|
store_id | int | The ID of the store. |
store_name | string | The name of the store. |
url_id | int | The ID of the URL. |
url | string | The product's buy URL. |
trend | string | Price trend ('up', 'down', 'lowest', 'none'). |
price | float | The most recent price. |
history | array | An associative array of date => price. |
last_scrape | string | The timestamp of the last successful scrape. |
locale | string | The locale for currency formatting (e.g., 'en_AU'). |
currency | string | The currency ISO code (e.g., 'AUD'). |
Sources: app/Models/Product.php:382-401
Price History and Aggregation
The Product model provides several accessor attributes to compute and format price data from its price_cache.
Trend Calculation
The trend attribute on a Product provides a quick overview of the current price relative to its history. It is calculated by Trend::calculateTrend().
// app/Models/Product.php:191-201
public function trend(): Attribute
{
return Attribute::make(
get: function (): string {
return Trend::calculateTrend(
$this->current_price,
$this->getPriceCacheAggregate('avg'),
$this->getPriceCacheAggregate('min'),
);
},
);
}
Sources: app/Models/Product.php:191-201, tests/Feature/Models/ProductTest.php:90-103
Price Aggregates
The price_aggregates attribute provides formatted strings for the maximum, average, and minimum prices from the product's history, derived from the cache.
// app/Models/Product.php:211-218
public function priceAggregates(): Attribute
{
return Attribute::make(
get: fn (): Collection => collect(['max', 'avg', 'min'])
->mapWithKeys(fn ($method) => [$method => $this->getPriceCacheAggregate($method)])
->filter(fn ($value) => $value > 0)
->mapWithKeys(fn ($value, $method) => [$method => CurrencyHelper::toString($value)])
);
}
Sources: app/Models/Product.php:211-218, tests/Feature/Models/ProductTest.php:105-114
Visualization
The application provides two charts on the product view page to visualize price history.
Sources: resources/views/filament/resources/product-resource/pages/view.blade.php:49-54
Price History Chart
A line chart displays the price history for each tracked URL over time. This is powered by the @livewire(\App\Filament\Resources\ProductResource\Widgets\PriceHistoryChart::class) component. The data for this chart is sourced from the Product::getPriceHistoryCached() method, which reads the pre-computed history from the price_cache.
Sources: resources/views/filament/resources/product-resource/pages/view.blade.php:49-50, app/Models/Product.php:497-503
Min/Max/Avg Range Chart
A range chart visualizes the minimum, maximum, and average prices across all stores for each day. This gives a sense of the price spread in the market over time. This chart is rendered by the <x-range-chart> Blade component.
The data is prepared by the Product::getAggregateRange() method.
This diagram shows the logic flow for preparing data for the min/max/avg range chart.
Sources: app/Models/Product.php:403-426, resources/views/filament/resources/product-resource/pages/view.blade.php:51-53
Adding New Products and Prices
New products and their initial prices are typically created by providing a URL. The system handles the scraping and record creation automatically.
Url::createFromUrl() Flow
This static method is the primary entry point for adding a new tracked URL.
This diagram shows how a new URL is processed to create or update a product and its price.
Sources: app/Models/Url.php:116-147, tests/Feature/Filament/ProductTest.php:126-163
This process is utilized in the Filament admin panel when a user adds a new URL, either to a new or an existing product. The tests confirm this behavior.
Sources: tests/Feature/Filament/ProductTest.php:91-193
Seeding Price Data
The ProductSeeder class provides a way to populate the database with dummy and real product data for development and testing. The runDummy() method demonstrates how to create products and a series of historical prices programmatically, which is then used to build the price cache.
// database/seeders/ProductSeeder.php:42-56
public function runDummy(): void
{
$userId = User::oldest('id')->first()?->id ?? User::factory()->create()->id;
foreach ($this->dummy as $productData) {
$factory = Product::factory();
foreach ($productData['urls'] as $url => $prices) {
$factory = $factory->addUrlWithPrices($url, $prices);
}
/** @var Product $product */
$product = $factory->createOne([
'title' => $productData['title'],
'image' => $productData['image'],
'user_id' => $userId,
]);
$product->updatePriceCache();
// ... tag logic
}
}