Ratings and Comments¶
This page covers Pathary's unique popcorn rating system and how ratings are stored and displayed.
Popcorn Rating Scale¶
Pathary uses a 1-7 popcorn scale instead of traditional 5 or 10-star ratings:
| Rating | Meaning |
|---|---|
| 1 | Terrible |
| 2 | Bad |
| 3 | Below average |
| 4 | Average |
| 5 | Good |
| 6 | Great |
| 7 | Masterpiece |
Visual representation: πΏπΏπΏπΏπΏπΏπΏ
Rating Data Model¶
Table: movie_user_rating¶
File: db/migrations/mysql/20251214000000_AddWatchedDateAndLocationToMovieUserRating.php
CREATE TABLE movie_user_rating (
movie_id INT UNSIGNED NOT NULL,
user_id INT UNSIGNED NOT NULL,
rating TINYINT, -- Legacy 1-10 rating
rating_popcorn TINYINT UNSIGNED, -- Popcorn 1-7 rating
comment TEXT, -- User's review
watched_year SMALLINT UNSIGNED, -- When watched (year)
watched_month TINYINT UNSIGNED, -- When watched (month, optional)
watched_day TINYINT UNSIGNED, -- When watched (day, optional)
location_id TINYINT UNSIGNED, -- Where watched
created_at TIMESTAMP,
updated_at TIMESTAMP,
PRIMARY KEY (movie_id, user_id)
);
Location Options¶
| ID | Label |
|---|---|
| 1 | Cinema |
| 2 | At Home |
| 3 | Other |
File: src/HttpController/Web/RateMovieController.php
public const int LOCATION_CINEMA = 1;
public const int LOCATION_AT_HOME = 2;
public const int LOCATION_OTHER = 3;
public const array LOCATION_LABELS = [
self::LOCATION_CINEMA => 'Cinema',
self::LOCATION_AT_HOME => 'At Home',
self::LOCATION_OTHER => 'Other',
];
Rating Submission¶
Submit Rating Flow¶
User submits rating form
β
POST /movie/{id}/rate
β
RateMovieController::rate()
β
MovieRepository::upsertUserRatingWithComment()
β
Redirect to movie page
Controller Implementation¶
File: src/HttpController/Web/RateMovieController.php
public function rate(Request $request) : Response
{
$movieId = (int)$request->getRouteParameters()['id'];
$userId = $this->authenticationService->getCurrentUserId();
$postData = $request->getPostParameters();
// Parse rating (0 means unrated)
$ratingValue = isset($postData['rating_popcorn']) ? (int)$postData['rating_popcorn'] : 0;
$ratingPopcorn = ($ratingValue >= 1 && $ratingValue <= 7)
? PopcornRating::create($ratingValue)
: null;
// Parse comment
$comment = isset($postData['comment']) && trim($postData['comment']) !== ''
? trim($postData['comment'])
: null;
// Parse watched date (partial date support)
$watchedYear = $this->parseIntOrNull($postData['watched_year'] ?? null);
$watchedMonth = $this->parseIntOrNull($postData['watched_month'] ?? null);
$watchedDay = $this->parseIntOrNull($postData['watched_day'] ?? null);
// Validate date hierarchy
if ($watchedDay !== null && $watchedMonth === null) {
$watchedDay = null;
}
if ($watchedMonth !== null && $watchedYear === null) {
$watchedMonth = null;
$watchedDay = null;
}
// Parse location
$locationId = $this->parseIntOrNull($postData['location_id'] ?? null);
// Save rating
$this->movieRepository->upsertUserRatingWithComment(
$movieId, $userId, $ratingPopcorn, $comment,
$watchedYear, $watchedMonth, $watchedDay, $locationId,
);
return Response::createSeeOther('/movie/' . $movieId . '#ratings');
}
Partial Date Support¶
Users can record when they watched a movie with varying precision:
| Precision | Example Display |
|---|---|
| Full date | 14.12.2024 |
| Month + Year | 12.2024 |
| Year only | 2024 |
Template logic (templates/public/movie_detail.twig):
{% if rating.watched_day and rating.watched_month %}
{{ '%02d'|format(rating.watched_day) }}.{{ '%02d'|format(rating.watched_month) }}.{{ rating.watched_year }}
{% elseif rating.watched_month %}
{{ '%02d'|format(rating.watched_month) }}.{{ rating.watched_year }}
{% else %}
{{ rating.watched_year }}
{% endif %}
Delete Rating¶
Delete Flow¶
User clicks delete button
β
POST /movie/{id}/rate/delete
β
RateMovieController::deleteRating()
β
MovieRepository::deleteUserRating()
β
Redirect to movie page
File: src/HttpController/Web/RateMovieController.php
public function deleteRating(Request $request) : Response
{
$movieId = (int)$request->getRouteParameters()['id'];
$userId = $this->authenticationService->getCurrentUserId();
$this->movieRepository->deleteUserRating($movieId, $userId);
return Response::createSeeOther('/movie/' . $movieId . '#ratings');
}
Global Rating Calculation¶
Pathary calculates an average rating across all users.
GroupMovieService¶
File: src/Service/GroupMovieService.php:getMovieGroupStats()
Calculates movie statistics by querying average popcorn rating and rating count from movie_user_rating (filtering for non-null popcorn ratings). Also queries movie_user_watch_dates for watch activity. Returns rounded average (null if no ratings), rating count, and the latest timestamp from either rating updates or watch dates.
Individual Ratings¶
File: src/Service/GroupMovieService.php:getMovieIndividualRatings()
Fetches individual user ratings for a movie by joining movie_user_rating with user table. Filters out completely empty ratings (requires at least one of: popcorn rating, comment, watched date, or location). Returns rating data with user name, shuffled in random order.
UI Components¶
Rating Form¶
File: templates/public/movie_detail.twig
The rating form uses a 3-column layout: 1. Left: Popcorn rating selector 2. Middle: Watched date (3 dropdowns) 3. Right: Location dropdown
<div class="rating-form-row">
<div class="rating-form-col">
<label>Rating</label>
{% include 'components/popcorn_rating.twig' %}
</div>
<div class="rating-form-divider"></div>
<div class="rating-form-col">
<label>Watched Date</label>
<div class="date-dropdowns">
<select name="watched_day">...</select>
<select name="watched_month">...</select>
<select name="watched_year">...</select>
</div>
</div>
<div class="rating-form-divider"></div>
<div class="rating-form-col">
<label>Location</label>
<select name="location_id">...</select>
</div>
</div>
Popcorn Rating Widget¶
File: templates/components/popcorn_rating.twig
Interactive rating selector using buttons:
<div class="popcorn-rating popcorn-rating--input">
<input type="hidden" name="rating_popcorn" value="{{ valueInt }}">
{% for i in 1..7 %}
<button type="button"
class="popcorn-rating__item {{ i <= valueInt ? 'popcorn-on' : 'popcorn-off' }}"
data-value="{{ i }}">
πΏ
</button>
{% endfor %}
</div>
Rating Display¶
Individual ratings are shown in cards:
<div class="rating-card">
<div class="rating-card-header">
<span class="rating-user-name">{{ rating.user_name }}</span>
<div class="popcorn-rating popcorn-rating--small">
{% for i in 1..7 %}
<span class="{{ i <= rating.rating_popcorn ? 'popcorn-on' : 'popcorn-off' }}">πΏ</span>
{% endfor %}
</div>
</div>
{% if rating.comment %}
<p class="rating-comment">"{{ rating.comment }}"</p>
{% endif %}
<div class="rating-meta">
{% if rating.watched_year %}
<span><i class="bi bi-calendar-event"></i> {{ watchedDateDisplay }}</span>
{% endif %}
{% if rating.location_id %}
<span><i class="bi bi-geo-alt"></i> {{ locationLabel }}</span>
{% endif %}
</div>
</div>
JavaScript¶
Date Dropdown Logic¶
File: templates/public/movie_detail.twig (inline script)
function updateDateDropdowns() {
const year = yearSelect.value;
const month = monthSelect.value;
// Month requires year
if (!year) {
monthSelect.disabled = true;
monthSelect.value = '';
daySelect.disabled = true;
daySelect.value = '';
} else {
monthSelect.disabled = false;
if (!month) {
daySelect.disabled = true;
daySelect.value = '';
} else {
daySelect.disabled = false;
updateDayOptions(parseInt(year), parseInt(month));
}
}
}
function updateDayOptions(year, month) {
const daysInMonth = new Date(year, month, 0).getDate();
// Update day dropdown options...
}
CSS Styling¶
File: templates/public/movie_detail.twig (style block)
.rating-form-row {
display: flex;
gap: 0;
}
.rating-form-col {
flex: 1;
padding: 0 1rem;
}
.rating-form-divider {
width: 2px;
background: linear-gradient(180deg, transparent, var(--accent-purple), transparent);
box-shadow: 0 0 8px rgba(111, 45, 189, 0.3);
}
.popcorn-rating__item.popcorn-on {
opacity: 1;
}
.popcorn-rating__item.popcorn-off {
opacity: 0.3;
}
Related Pages¶
- Database - Rating table schema
- Movies and TMDB - Movie data
- Frontend and UI - UI components