Compare commits
19 Commits
d034af9e9c
...
76319756f4
| Author | SHA1 | Date | |
|---|---|---|---|
| 76319756f4 | |||
| 7703227970 | |||
| b9933bb48d | |||
| 0c48708ce6 | |||
| a2a889bced | |||
| a4846f3bea | |||
| 27be840faa | |||
| 965fc0eda8 | |||
| d700b85337 | |||
| ffbab75910 | |||
| dda7c40f7f | |||
| 1b827b1bdd | |||
| 1ee6873a60 | |||
| 7352b533ff | |||
| 85e254fee2 | |||
| fa8221322d | |||
| 38da37de55 | |||
| f3dedbad8a | |||
| d468ce131f |
20
.sqlx/query-0cd1a7b7255a0ee753deffab7cbb48027d22900a570b98a636c780cb3e2efd23.json
generated
Normal file
20
.sqlx/query-0cd1a7b7255a0ee753deffab7cbb48027d22900a570b98a636c780cb3e2efd23.json
generated
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT COUNT(*) FROM reviews WHERE user_id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "COUNT(*)",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "0cd1a7b7255a0ee753deffab7cbb48027d22900a570b98a636c780cb3e2efd23"
|
||||||
|
}
|
||||||
98
.sqlx/query-217854179b4f77897178e6cfae51fb743e5be49ffc59826509be37a7cc81b6ee.json
generated
Normal file
98
.sqlx/query-217854179b4f77897178e6cfae51fb743e5be49ffc59826509be37a7cc81b6ee.json
generated
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at,\n u.email AS user_email\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n INNER JOIN users u ON u.id = r.user_id\n ORDER BY r.watched_at DESC\n LIMIT ? OFFSET ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "external_metadata_id",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "release_year",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "director",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "poster_path",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "review_id",
|
||||||
|
"ordinal": 6,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "movie_id",
|
||||||
|
"ordinal": 7,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "user_id",
|
||||||
|
"ordinal": 8,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rating",
|
||||||
|
"ordinal": 9,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "comment",
|
||||||
|
"ordinal": 10,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "watched_at",
|
||||||
|
"ordinal": 11,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at",
|
||||||
|
"ordinal": 12,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "user_email",
|
||||||
|
"ordinal": 13,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "217854179b4f77897178e6cfae51fb743e5be49ffc59826509be37a7cc81b6ee"
|
||||||
|
}
|
||||||
38
.sqlx/query-41273bd5f2ad4e86bb2f60d7b3144279f2ae77a95a8ea61bbf3dbfef2d861dd8.json
generated
Normal file
38
.sqlx/query-41273bd5f2ad4e86bb2f60d7b3144279f2ae77a95a8ea61bbf3dbfef2d861dd8.json
generated
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT u.id,\n u.email,\n COUNT(r.id) AS \"total_movies!: i64\",\n AVG(CAST(r.rating AS REAL)) AS avg_rating\n FROM users u\n LEFT JOIN reviews r ON r.user_id = u.id\n GROUP BY u.id, u.email\n ORDER BY u.email ASC",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "email",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "total_movies!: i64",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "avg_rating",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Float"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 0
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "41273bd5f2ad4e86bb2f60d7b3144279f2ae77a95a8ea61bbf3dbfef2d861dd8"
|
||||||
|
}
|
||||||
20
.sqlx/query-4d85f0ff9732576bba77dc84d3885a0002c2b600c34ba4d99f1e1c5e99f35e75.json
generated
Normal file
20
.sqlx/query-4d85f0ff9732576bba77dc84d3885a0002c2b600c34ba4d99f1e1c5e99f35e75.json
generated
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT strftime('%Y-%m', watched_at) AS month\n FROM reviews\n WHERE user_id = ?\n GROUP BY month\n ORDER BY COUNT(*) DESC\n LIMIT 1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "month",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "4d85f0ff9732576bba77dc84d3885a0002c2b600c34ba4d99f1e1c5e99f35e75"
|
||||||
|
}
|
||||||
92
.sqlx/query-5a861b5a934c9831ff17d896fa48feb95e6dab051c5ac55a66f9793482522199.json
generated
Normal file
92
.sqlx/query-5a861b5a934c9831ff17d896fa48feb95e6dab051c5ac55a66f9793482522199.json
generated
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.user_id = ?\n ORDER BY r.watched_at DESC\n LIMIT ? OFFSET ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "external_metadata_id",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "release_year",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "director",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "poster_path",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "review_id",
|
||||||
|
"ordinal": 6,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "movie_id",
|
||||||
|
"ordinal": 7,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "user_id",
|
||||||
|
"ordinal": 8,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rating",
|
||||||
|
"ordinal": 9,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "comment",
|
||||||
|
"ordinal": 10,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "watched_at",
|
||||||
|
"ordinal": 11,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at",
|
||||||
|
"ordinal": 12,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 3
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "5a861b5a934c9831ff17d896fa48feb95e6dab051c5ac55a66f9793482522199"
|
||||||
|
}
|
||||||
92
.sqlx/query-8d144859b397a842118c2dc4ab30e74015a814ed8185b6f86fbe39e641ab804e.json
generated
Normal file
92
.sqlx/query-8d144859b397a842118c2dc4ab30e74015a814ed8185b6f86fbe39e641ab804e.json
generated
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.user_id = ?\n ORDER BY r.watched_at DESC",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "external_metadata_id",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "release_year",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "director",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "poster_path",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "review_id",
|
||||||
|
"ordinal": 6,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "movie_id",
|
||||||
|
"ordinal": 7,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "user_id",
|
||||||
|
"ordinal": 8,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rating",
|
||||||
|
"ordinal": 9,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "comment",
|
||||||
|
"ordinal": 10,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "watched_at",
|
||||||
|
"ordinal": 11,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at",
|
||||||
|
"ordinal": 12,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "8d144859b397a842118c2dc4ab30e74015a814ed8185b6f86fbe39e641ab804e"
|
||||||
|
}
|
||||||
92
.sqlx/query-a3f4385bac7f78a9959648fb325d37096c87859ded1762137ce745955f46830c.json
generated
Normal file
92
.sqlx/query-a3f4385bac7f78a9959648fb325d37096c87859ded1762137ce745955f46830c.json
generated
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.user_id = ?\n ORDER BY r.rating DESC, r.watched_at DESC\n LIMIT ? OFFSET ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "external_metadata_id",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "release_year",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "director",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "poster_path",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "review_id",
|
||||||
|
"ordinal": 6,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "movie_id",
|
||||||
|
"ordinal": 7,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "user_id",
|
||||||
|
"ordinal": 8,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rating",
|
||||||
|
"ordinal": 9,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "comment",
|
||||||
|
"ordinal": 10,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "watched_at",
|
||||||
|
"ordinal": 11,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at",
|
||||||
|
"ordinal": 12,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 3
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "a3f4385bac7f78a9959648fb325d37096c87859ded1762137ce745955f46830c"
|
||||||
|
}
|
||||||
26
.sqlx/query-aca9e7aaa32c23b4de3f5048d60340e978d31a36be9121da3c59378f2fc1ed8e.json
generated
Normal file
26
.sqlx/query-aca9e7aaa32c23b4de3f5048d60340e978d31a36be9121da3c59378f2fc1ed8e.json
generated
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT m.director AS \"director!\",\n COUNT(*) AS \"count!: i64\"\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.user_id = ? AND m.director IS NOT NULL\n GROUP BY m.director\n ORDER BY COUNT(*) DESC\n LIMIT 5",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "director!",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "count!: i64",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "aca9e7aaa32c23b4de3f5048d60340e978d31a36be9121da3c59378f2fc1ed8e"
|
||||||
|
}
|
||||||
26
.sqlx/query-d59e1a103fc56b9b4579add523f0f77b68500cf4c96002a4a17b1e40093504ba.json
generated
Normal file
26
.sqlx/query-d59e1a103fc56b9b4579add523f0f77b68500cf4c96002a4a17b1e40093504ba.json
generated
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT COUNT(*) AS \"total!: i64\",\n AVG(CAST(rating AS REAL)) AS avg_rating\n FROM reviews WHERE user_id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "total!: i64",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "avg_rating",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Float"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "d59e1a103fc56b9b4579add523f0f77b68500cf4c96002a4a17b1e40093504ba"
|
||||||
|
}
|
||||||
20
.sqlx/query-d5d2a81306488a8cee5654cea7e14d76d76ecc7d2190ffb73d12bec2874111d2.json
generated
Normal file
20
.sqlx/query-d5d2a81306488a8cee5654cea7e14d76d76ecc7d2190ffb73d12bec2874111d2.json
generated
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT m.director\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.user_id = ? AND m.director IS NOT NULL\n GROUP BY m.director\n ORDER BY COUNT(*) DESC\n LIMIT 1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "director",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "d5d2a81306488a8cee5654cea7e14d76d76ecc7d2190ffb73d12bec2874111d2"
|
||||||
|
}
|
||||||
32
.sqlx/query-fdd5b522f26b5e0ce62f76c774fbb606fd9ee9884f4457831f693a0df3609317.json
generated
Normal file
32
.sqlx/query-fdd5b522f26b5e0ce62f76c774fbb606fd9ee9884f4457831f693a0df3609317.json
generated
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT strftime('%Y-%m', watched_at) AS \"month!\",\n AVG(CAST(rating AS REAL)) AS \"avg_rating!: f64\",\n COUNT(*) AS \"count!: i64\"\n FROM reviews\n WHERE user_id = ? AND watched_at >= datetime('now', '-12 months')\n GROUP BY \"month!\"\n ORDER BY \"month!\" ASC",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "month!",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "avg_rating!: f64",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Float"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "count!: i64",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "fdd5b522f26b5e0ce62f76c774fbb606fd9ee9884f4457831f693a0df3609317"
|
||||||
|
}
|
||||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -2831,6 +2831,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"application",
|
"application",
|
||||||
"askama",
|
"askama",
|
||||||
|
"chrono",
|
||||||
"domain",
|
"domain",
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ use domain::{
|
|||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::{
|
models::{
|
||||||
DiaryEntry, DiaryFilter, Movie, Review, ReviewHistory, SortDirection,
|
DiaryEntry, DiaryFilter, DirectorStat, FeedEntry, Movie, MonthlyRating,
|
||||||
collections::Paginated,
|
Review, ReviewHistory, SortDirection, UserStats, UserTrends,
|
||||||
|
collections::{PageParams, Paginated},
|
||||||
},
|
},
|
||||||
ports::MovieRepository,
|
ports::MovieRepository,
|
||||||
value_objects::{ExternalMetadataId, MovieId, MovieTitle, ReleaseYear, ReviewId},
|
value_objects::{ExternalMetadataId, MovieId, MovieTitle, ReleaseYear, ReviewId, UserId},
|
||||||
};
|
};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
@@ -15,10 +16,26 @@ mod migrations;
|
|||||||
mod models;
|
mod models;
|
||||||
mod users;
|
mod users;
|
||||||
|
|
||||||
use models::{DiaryRow, MovieRow, ReviewRow, datetime_to_str};
|
use models::{
|
||||||
|
DiaryRow, DirectorCountRow, FeedRow, MonthlyRatingRow, MovieRow, ReviewRow,
|
||||||
|
UserTotalsRow, datetime_to_str,
|
||||||
|
};
|
||||||
|
|
||||||
pub use users::SqliteUserRepository;
|
pub use users::SqliteUserRepository;
|
||||||
|
|
||||||
|
fn format_year_month(ym: &str) -> String {
|
||||||
|
let parts: Vec<&str> = ym.splitn(2, '-').collect();
|
||||||
|
if parts.len() != 2 { return ym.to_string(); }
|
||||||
|
let year = parts[0].get(2..).unwrap_or(parts[0]);
|
||||||
|
let month = match parts[1] {
|
||||||
|
"01" => "Jan", "02" => "Feb", "03" => "Mar", "04" => "Apr",
|
||||||
|
"05" => "May", "06" => "Jun", "07" => "Jul", "08" => "Aug",
|
||||||
|
"09" => "Sep", "10" => "Oct", "11" => "Nov", "12" => "Dec",
|
||||||
|
_ => parts[1],
|
||||||
|
};
|
||||||
|
format!("{} '{}", month, year)
|
||||||
|
}
|
||||||
|
|
||||||
pub struct SqliteMovieRepository {
|
pub struct SqliteMovieRepository {
|
||||||
pool: SqlitePool,
|
pool: SqlitePool,
|
||||||
}
|
}
|
||||||
@@ -59,7 +76,8 @@ impl SqliteMovieRepository {
|
|||||||
offset: i64,
|
offset: i64,
|
||||||
) -> Result<Vec<DiaryRow>, DomainError> {
|
) -> Result<Vec<DiaryRow>, DomainError> {
|
||||||
match sort {
|
match sort {
|
||||||
SortDirection::Descending => sqlx::query_as!(
|
// ByRatingDesc only applies to user-scoped queries; falls back to date sort here
|
||||||
|
SortDirection::Descending | SortDirection::ByRatingDesc => sqlx::query_as!(
|
||||||
DiaryRow,
|
DiaryRow,
|
||||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at
|
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at
|
||||||
@@ -99,7 +117,8 @@ impl SqliteMovieRepository {
|
|||||||
offset: i64,
|
offset: i64,
|
||||||
) -> Result<Vec<DiaryRow>, DomainError> {
|
) -> Result<Vec<DiaryRow>, DomainError> {
|
||||||
match sort {
|
match sort {
|
||||||
SortDirection::Descending => sqlx::query_as!(
|
// ByRatingDesc only applies to user-scoped queries; falls back to date sort here
|
||||||
|
SortDirection::Descending | SortDirection::ByRatingDesc => sqlx::query_as!(
|
||||||
DiaryRow,
|
DiaryRow,
|
||||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at
|
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at
|
||||||
@@ -134,6 +153,141 @@ impl SqliteMovieRepository {
|
|||||||
.map_err(Self::map_err),
|
.map_err(Self::map_err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn count_user_diary_entries(&self, user_id: &str) -> Result<i64, DomainError> {
|
||||||
|
sqlx::query_scalar!(
|
||||||
|
"SELECT COUNT(*) FROM reviews WHERE user_id = ?",
|
||||||
|
user_id
|
||||||
|
)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(Self::map_err)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_user_diary_rows_by_watched(
|
||||||
|
&self,
|
||||||
|
user_id: &str,
|
||||||
|
limit: i64,
|
||||||
|
offset: i64,
|
||||||
|
) -> Result<Vec<DiaryRow>, DomainError> {
|
||||||
|
sqlx::query_as!(
|
||||||
|
DiaryRow,
|
||||||
|
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||||
|
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at
|
||||||
|
FROM reviews r
|
||||||
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
|
WHERE r.user_id = ?
|
||||||
|
ORDER BY r.watched_at DESC
|
||||||
|
LIMIT ? OFFSET ?",
|
||||||
|
user_id, limit, offset
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(Self::map_err)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_user_diary_rows_by_rating(
|
||||||
|
&self,
|
||||||
|
user_id: &str,
|
||||||
|
limit: i64,
|
||||||
|
offset: i64,
|
||||||
|
) -> Result<Vec<DiaryRow>, DomainError> {
|
||||||
|
sqlx::query_as!(
|
||||||
|
DiaryRow,
|
||||||
|
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||||
|
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at
|
||||||
|
FROM reviews r
|
||||||
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
|
WHERE r.user_id = ?
|
||||||
|
ORDER BY r.rating DESC, r.watched_at DESC
|
||||||
|
LIMIT ? OFFSET ?",
|
||||||
|
user_id, limit, offset
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(Self::map_err)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_feed_entries(&self) -> Result<i64, DomainError> {
|
||||||
|
sqlx::query_scalar!("SELECT COUNT(*) FROM reviews")
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(Self::map_err)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_feed_rows(
|
||||||
|
&self,
|
||||||
|
limit: i64,
|
||||||
|
offset: i64,
|
||||||
|
) -> Result<Vec<FeedRow>, DomainError> {
|
||||||
|
sqlx::query_as!(
|
||||||
|
FeedRow,
|
||||||
|
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||||
|
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at,
|
||||||
|
u.email AS user_email
|
||||||
|
FROM reviews r
|
||||||
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
|
INNER JOIN users u ON u.id = r.user_id
|
||||||
|
ORDER BY r.watched_at DESC
|
||||||
|
LIMIT ? OFFSET ?",
|
||||||
|
limit, offset
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(Self::map_err)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_user_totals(&self, user_id: &str) -> Result<UserTotalsRow, DomainError> {
|
||||||
|
sqlx::query_as!(
|
||||||
|
UserTotalsRow,
|
||||||
|
r#"SELECT COUNT(*) AS "total!: i64",
|
||||||
|
AVG(CAST(rating AS REAL)) AS avg_rating
|
||||||
|
FROM reviews WHERE user_id = ?"#,
|
||||||
|
user_id
|
||||||
|
)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(Self::map_err)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_user_favorite_director(
|
||||||
|
&self,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<Option<String>, DomainError> {
|
||||||
|
let row = sqlx::query_scalar!(
|
||||||
|
"SELECT m.director
|
||||||
|
FROM reviews r
|
||||||
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
|
WHERE r.user_id = ? AND m.director IS NOT NULL
|
||||||
|
GROUP BY m.director
|
||||||
|
ORDER BY COUNT(*) DESC
|
||||||
|
LIMIT 1",
|
||||||
|
user_id
|
||||||
|
)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(Self::map_err)?;
|
||||||
|
Ok(row.flatten())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_user_most_active_month(
|
||||||
|
&self,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<Option<String>, DomainError> {
|
||||||
|
let result: Option<Option<String>> = sqlx::query_scalar!(
|
||||||
|
"SELECT strftime('%Y-%m', watched_at) AS month
|
||||||
|
FROM reviews
|
||||||
|
WHERE user_id = ?
|
||||||
|
GROUP BY month
|
||||||
|
ORDER BY COUNT(*) DESC
|
||||||
|
LIMIT 1",
|
||||||
|
user_id
|
||||||
|
)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(Self::map_err)?;
|
||||||
|
Ok(result.flatten())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -261,18 +415,36 @@ impl MovieRepository for SqliteMovieRepository {
|
|||||||
let limit = filter.page.limit as i64;
|
let limit = filter.page.limit as i64;
|
||||||
let offset = filter.page.offset as i64;
|
let offset = filter.page.offset as i64;
|
||||||
|
|
||||||
let (total, rows) = match &filter.movie_id {
|
let (total, rows) = match (&filter.movie_id, &filter.user_id) {
|
||||||
None => tokio::try_join!(
|
(None, None) => tokio::try_join!(
|
||||||
self.count_diary_entries(None),
|
self.count_diary_entries(None),
|
||||||
self.fetch_all_diary_rows(&filter.sort_by, limit, offset)
|
self.fetch_all_diary_rows(&filter.sort_by, limit, offset)
|
||||||
)?,
|
)?,
|
||||||
Some(id) => {
|
(Some(id), None) => {
|
||||||
let id_str = id.value().to_string();
|
let id_str = id.value().to_string();
|
||||||
tokio::try_join!(
|
tokio::try_join!(
|
||||||
self.count_diary_entries(Some(id_str.as_str())),
|
self.count_diary_entries(Some(id_str.as_str())),
|
||||||
self.fetch_movie_diary_rows(&id_str, &filter.sort_by, limit, offset)
|
self.fetch_movie_diary_rows(&id_str, &filter.sort_by, limit, offset)
|
||||||
)?
|
)?
|
||||||
}
|
}
|
||||||
|
(None, Some(uid)) => {
|
||||||
|
let uid_str = uid.value().to_string();
|
||||||
|
match &filter.sort_by {
|
||||||
|
SortDirection::ByRatingDesc => tokio::try_join!(
|
||||||
|
self.count_user_diary_entries(&uid_str),
|
||||||
|
self.fetch_user_diary_rows_by_rating(&uid_str, limit, offset)
|
||||||
|
)?,
|
||||||
|
_ => tokio::try_join!(
|
||||||
|
self.count_user_diary_entries(&uid_str),
|
||||||
|
self.fetch_user_diary_rows_by_watched(&uid_str, limit, offset)
|
||||||
|
)?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Some(_), Some(_)) => {
|
||||||
|
return Err(DomainError::ValidationError(
|
||||||
|
"Combined movie_id + user_id filter not supported".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let items = rows
|
let items = rows
|
||||||
@@ -351,4 +523,119 @@ impl MovieRepository for SqliteMovieRepository {
|
|||||||
|
|
||||||
Ok(ReviewHistory::new(movie, viewings))
|
Ok(ReviewHistory::new(movie, viewings))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn query_activity_feed(
|
||||||
|
&self,
|
||||||
|
page: &PageParams,
|
||||||
|
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
|
let limit = page.limit as i64;
|
||||||
|
let offset = page.offset as i64;
|
||||||
|
|
||||||
|
let (total, rows) = tokio::try_join!(
|
||||||
|
self.count_feed_entries(),
|
||||||
|
self.fetch_feed_rows(limit, offset)
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let items = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(FeedRow::to_domain)
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
Ok(Paginated {
|
||||||
|
items,
|
||||||
|
total_count: total as u64,
|
||||||
|
limit: page.limit,
|
||||||
|
offset: page.offset,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_user_stats(&self, user_id: &UserId) -> Result<UserStats, DomainError> {
|
||||||
|
let uid = user_id.value().to_string();
|
||||||
|
|
||||||
|
let (totals, fav_director, most_active) = tokio::try_join!(
|
||||||
|
self.fetch_user_totals(&uid),
|
||||||
|
self.fetch_user_favorite_director(&uid),
|
||||||
|
self.fetch_user_most_active_month(&uid)
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let most_active_month = most_active.map(|ym| format_year_month(&ym));
|
||||||
|
|
||||||
|
Ok(UserStats {
|
||||||
|
total_movies: totals.total,
|
||||||
|
avg_rating: totals.avg_rating,
|
||||||
|
favorite_director: fav_director,
|
||||||
|
most_active_month,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_user_history(&self, user_id: &UserId) -> Result<Vec<DiaryEntry>, DomainError> {
|
||||||
|
let uid = user_id.value().to_string();
|
||||||
|
let rows = sqlx::query_as!(
|
||||||
|
DiaryRow,
|
||||||
|
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||||
|
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at
|
||||||
|
FROM reviews r
|
||||||
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
|
WHERE r.user_id = ?
|
||||||
|
ORDER BY r.watched_at DESC",
|
||||||
|
uid
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(Self::map_err)?;
|
||||||
|
|
||||||
|
rows.into_iter().map(DiaryRow::to_domain).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_user_trends(&self, user_id: &UserId) -> Result<UserTrends, DomainError> {
|
||||||
|
let uid = user_id.value().to_string();
|
||||||
|
|
||||||
|
let (rating_rows, director_rows) = tokio::try_join!(
|
||||||
|
sqlx::query_as!(
|
||||||
|
MonthlyRatingRow,
|
||||||
|
r#"SELECT strftime('%Y-%m', watched_at) AS "month!",
|
||||||
|
AVG(CAST(rating AS REAL)) AS "avg_rating!: f64",
|
||||||
|
COUNT(*) AS "count!: i64"
|
||||||
|
FROM reviews
|
||||||
|
WHERE user_id = ? AND watched_at >= datetime('now', '-12 months')
|
||||||
|
GROUP BY "month!"
|
||||||
|
ORDER BY "month!" ASC"#,
|
||||||
|
uid
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool),
|
||||||
|
sqlx::query_as!(
|
||||||
|
DirectorCountRow,
|
||||||
|
r#"SELECT m.director AS "director!",
|
||||||
|
COUNT(*) AS "count!: i64"
|
||||||
|
FROM reviews r
|
||||||
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
|
WHERE r.user_id = ? AND m.director IS NOT NULL
|
||||||
|
GROUP BY m.director
|
||||||
|
ORDER BY COUNT(*) DESC
|
||||||
|
LIMIT 5"#,
|
||||||
|
uid
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
)
|
||||||
|
.map_err(Self::map_err)?;
|
||||||
|
|
||||||
|
let max_director_count = director_rows.iter().map(|d| d.count).max().unwrap_or(1);
|
||||||
|
|
||||||
|
let monthly_ratings = rating_rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| MonthlyRating {
|
||||||
|
month_label: format_year_month(&r.month),
|
||||||
|
year_month: r.month,
|
||||||
|
avg_rating: r.avg_rating,
|
||||||
|
count: r.count,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let top_directors = director_rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|d| DirectorStat { director: d.director, count: d.count })
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(UserTrends { monthly_ratings, top_directors, max_director_count })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::{DiaryEntry, Movie, Review},
|
models::{DiaryEntry, FeedEntry, Movie, Review, UserSummary},
|
||||||
value_objects::{
|
value_objects::{
|
||||||
Comment, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
|
Comment, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
|
||||||
ReviewId, UserId,
|
ReviewId, UserId,
|
||||||
@@ -111,6 +111,85 @@ impl DiaryRow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Like DiaryRow but includes user_email from JOIN with users table
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(crate) struct FeedRow {
|
||||||
|
pub id: String,
|
||||||
|
pub external_metadata_id: Option<String>,
|
||||||
|
pub title: String,
|
||||||
|
pub release_year: i64,
|
||||||
|
pub director: Option<String>,
|
||||||
|
pub poster_path: Option<String>,
|
||||||
|
pub review_id: String,
|
||||||
|
pub movie_id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub rating: i64,
|
||||||
|
pub comment: Option<String>,
|
||||||
|
pub watched_at: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub user_email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FeedRow {
|
||||||
|
pub fn to_domain(self) -> Result<FeedEntry, DomainError> {
|
||||||
|
let diary = DiaryRow {
|
||||||
|
id: self.id,
|
||||||
|
external_metadata_id: self.external_metadata_id,
|
||||||
|
title: self.title,
|
||||||
|
release_year: self.release_year,
|
||||||
|
director: self.director,
|
||||||
|
poster_path: self.poster_path,
|
||||||
|
review_id: self.review_id,
|
||||||
|
movie_id: self.movie_id,
|
||||||
|
user_id: self.user_id,
|
||||||
|
rating: self.rating,
|
||||||
|
comment: self.comment,
|
||||||
|
watched_at: self.watched_at,
|
||||||
|
created_at: self.created_at,
|
||||||
|
}
|
||||||
|
.to_domain()?;
|
||||||
|
Ok(FeedEntry::new(diary, self.user_email))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(crate) struct UserSummaryRow {
|
||||||
|
pub id: String,
|
||||||
|
pub email: String,
|
||||||
|
pub total_movies: i64,
|
||||||
|
pub avg_rating: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserSummaryRow {
|
||||||
|
pub fn to_domain(self) -> Result<UserSummary, DomainError> {
|
||||||
|
Ok(UserSummary {
|
||||||
|
user_id: UserId::from_uuid(parse_uuid(&self.id)?),
|
||||||
|
email: self.email,
|
||||||
|
total_movies: self.total_movies,
|
||||||
|
avg_rating: self.avg_rating,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(crate) struct UserTotalsRow {
|
||||||
|
pub total: i64,
|
||||||
|
pub avg_rating: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(crate) struct DirectorCountRow {
|
||||||
|
pub director: String,
|
||||||
|
pub count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(crate) struct MonthlyRatingRow {
|
||||||
|
pub month: String,
|
||||||
|
pub avg_rating: f64,
|
||||||
|
pub count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn parse_uuid(s: &str) -> Result<Uuid, DomainError> {
|
pub(crate) fn parse_uuid(s: &str) -> Result<Uuid, DomainError> {
|
||||||
Uuid::parse_str(s)
|
Uuid::parse_str(s)
|
||||||
.map_err(|e| DomainError::InfrastructureError(format!("Invalid UUID '{}': {}", s, e)))
|
.map_err(|e| DomainError::InfrastructureError(format!("Invalid UUID '{}': {}", s, e)))
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use domain::{
|
|||||||
ports::UserRepository,
|
ports::UserRepository,
|
||||||
value_objects::{Email, PasswordHash, UserId},
|
value_objects::{Email, PasswordHash, UserId},
|
||||||
};
|
};
|
||||||
|
use super::models::UserSummaryRow;
|
||||||
|
|
||||||
pub struct SqliteUserRepository {
|
pub struct SqliteUserRepository {
|
||||||
pool: SqlitePool,
|
pool: SqlitePool,
|
||||||
@@ -97,6 +98,26 @@ impl UserRepository for SqliteUserRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
|
||||||
|
sqlx::query_as!(
|
||||||
|
UserSummaryRow,
|
||||||
|
r#"SELECT u.id,
|
||||||
|
u.email,
|
||||||
|
COUNT(r.id) AS "total_movies!: i64",
|
||||||
|
AVG(CAST(r.rating AS REAL)) AS avg_rating
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN reviews r ON r.user_id = u.id
|
||||||
|
GROUP BY u.id, u.email
|
||||||
|
ORDER BY u.email ASC"#
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(Self::map_err)?
|
||||||
|
.into_iter()
|
||||||
|
.map(UserSummaryRow::to_domain)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ edition = "2024"
|
|||||||
askama = { version = "0.16.0" }
|
askama = { version = "0.16.0" }
|
||||||
|
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
application = { workspace = true }
|
application = { workspace = true }
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
use askama::Template;
|
use askama::Template;
|
||||||
|
use chrono::Datelike;
|
||||||
use application::ports::{
|
use application::ports::{
|
||||||
HtmlPageContext, HtmlRenderer, LoginPageData, NewReviewPageData, RegisterPageData,
|
ActivityFeedPageData, HtmlPageContext, HtmlRenderer, LoginPageData,
|
||||||
|
NewReviewPageData, ProfilePageData, RegisterPageData, UsersPageData,
|
||||||
|
};
|
||||||
|
use domain::models::{
|
||||||
|
DiaryEntry, FeedEntry, MonthActivity, MonthlyRating, UserStats, UserSummary, UserTrends,
|
||||||
|
collections::Paginated,
|
||||||
};
|
};
|
||||||
use domain::models::{DiaryEntry, collections::Paginated};
|
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "diary.html")]
|
#[template(path = "diary.html")]
|
||||||
@@ -35,6 +40,95 @@ struct NewReviewTemplate<'a> {
|
|||||||
ctx: &'a HtmlPageContext,
|
ctx: &'a HtmlPageContext,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "activity_feed.html")]
|
||||||
|
struct ActivityFeedTemplate<'a> {
|
||||||
|
entries: &'a [FeedEntry],
|
||||||
|
current_offset: u32,
|
||||||
|
limit: u32,
|
||||||
|
has_more: bool,
|
||||||
|
ctx: &'a HtmlPageContext,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "users.html")]
|
||||||
|
struct UsersTemplate<'a> {
|
||||||
|
users: &'a [UserSummary],
|
||||||
|
ctx: &'a HtmlPageContext,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MonthlyRatingRow<'a> {
|
||||||
|
rating: &'a MonthlyRating,
|
||||||
|
bar_height_pct: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "profile.html")]
|
||||||
|
struct ProfileTemplate<'a> {
|
||||||
|
ctx: &'a HtmlPageContext,
|
||||||
|
profile_display_name: String,
|
||||||
|
stats: &'a UserStats,
|
||||||
|
view: &'a str,
|
||||||
|
entries: Option<&'a Paginated<DiaryEntry>>,
|
||||||
|
current_offset: u32,
|
||||||
|
has_more: bool,
|
||||||
|
limit: u32,
|
||||||
|
history: Option<&'a Vec<MonthActivity>>,
|
||||||
|
trends: Option<&'a UserTrends>,
|
||||||
|
monthly_rating_rows: Vec<MonthlyRatingRow<'a>>,
|
||||||
|
heatmap: Vec<HeatmapCell>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HeatmapCell {
|
||||||
|
month_label: String,
|
||||||
|
count: i64,
|
||||||
|
alpha: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn relative_time(dt: chrono::NaiveDateTime) -> String {
|
||||||
|
let now = chrono::Utc::now().naive_utc();
|
||||||
|
let diff = now.signed_duration_since(dt);
|
||||||
|
if diff.num_seconds() <= 0 { return "just now".to_string(); }
|
||||||
|
let minutes = diff.num_minutes();
|
||||||
|
let hours = diff.num_hours();
|
||||||
|
let days = diff.num_days();
|
||||||
|
if minutes < 1 { return "just now".to_string(); }
|
||||||
|
if minutes < 60 { return format!("{} min ago", minutes); }
|
||||||
|
if hours < 24 { return format!("{} h ago", hours); }
|
||||||
|
if days == 1 { return "yesterday".to_string(); }
|
||||||
|
if days < 30 { return format!("{} days ago", days); }
|
||||||
|
dt.format("%b %-d, %Y").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_heatmap(history: &[MonthActivity]) -> Vec<HeatmapCell> {
|
||||||
|
let current_year = chrono::Utc::now().year();
|
||||||
|
let count_for = |m: &str| -> i64 {
|
||||||
|
history.iter().find(|a| a.year_month == format!("{}-{}", current_year, m))
|
||||||
|
.map(|a| a.count)
|
||||||
|
.unwrap_or(0)
|
||||||
|
};
|
||||||
|
let months = [
|
||||||
|
("01", "Jan"), ("02", "Feb"), ("03", "Mar"), ("04", "Apr"),
|
||||||
|
("05", "May"), ("06", "Jun"), ("07", "Jul"), ("08", "Aug"),
|
||||||
|
("09", "Sep"), ("10", "Oct"), ("11", "Nov"), ("12", "Dec"),
|
||||||
|
];
|
||||||
|
let counts: Vec<i64> = months.iter().map(|(m, _)| count_for(m)).collect();
|
||||||
|
let max = counts.iter().copied().max().unwrap_or(0).max(1);
|
||||||
|
months.iter().zip(counts.iter()).map(|((_, label), &count)| {
|
||||||
|
let alpha = if count == 0 { 0.05 } else { 0.15 + 0.75 * (count as f64 / max as f64) };
|
||||||
|
HeatmapCell {
|
||||||
|
month_label: label.to_string(),
|
||||||
|
count,
|
||||||
|
alpha,
|
||||||
|
}
|
||||||
|
}).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bar_height_pct(avg_rating: f64) -> i64 {
|
||||||
|
(avg_rating / 5.0 * 100.0) as i64
|
||||||
|
}
|
||||||
|
|
||||||
pub struct AskamaHtmlRenderer;
|
pub struct AskamaHtmlRenderer;
|
||||||
|
|
||||||
impl AskamaHtmlRenderer {
|
impl AskamaHtmlRenderer {
|
||||||
@@ -83,4 +177,55 @@ impl HtmlRenderer for AskamaHtmlRenderer {
|
|||||||
.render()
|
.render()
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_activity_feed_page(&self, data: ActivityFeedPageData) -> Result<String, String> {
|
||||||
|
ActivityFeedTemplate {
|
||||||
|
entries: &data.entries.items,
|
||||||
|
current_offset: data.current_offset,
|
||||||
|
limit: data.limit,
|
||||||
|
has_more: data.has_more,
|
||||||
|
ctx: &data.ctx,
|
||||||
|
}
|
||||||
|
.render()
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_users_page(&self, data: UsersPageData) -> Result<String, String> {
|
||||||
|
UsersTemplate {
|
||||||
|
users: &data.users,
|
||||||
|
ctx: &data.ctx,
|
||||||
|
}
|
||||||
|
.render()
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_profile_page(&self, data: ProfilePageData) -> Result<String, String> {
|
||||||
|
let heatmap = data.history.as_deref()
|
||||||
|
.map(|h| build_heatmap(h))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let profile_display_name = data.profile_user_email
|
||||||
|
.split('@').next().unwrap_or(&data.profile_user_email).to_string();
|
||||||
|
let monthly_rating_rows: Vec<MonthlyRatingRow<'_>> = data.trends.as_ref()
|
||||||
|
.map(|t| t.monthly_ratings.iter().map(|r| MonthlyRatingRow {
|
||||||
|
bar_height_pct: bar_height_pct(r.avg_rating),
|
||||||
|
rating: r,
|
||||||
|
}).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
ProfileTemplate {
|
||||||
|
ctx: &data.ctx,
|
||||||
|
profile_display_name,
|
||||||
|
stats: &data.stats,
|
||||||
|
view: &data.view,
|
||||||
|
entries: data.entries.as_ref(),
|
||||||
|
current_offset: data.current_offset,
|
||||||
|
has_more: data.has_more,
|
||||||
|
limit: data.limit,
|
||||||
|
history: data.history.as_ref(),
|
||||||
|
trends: data.trends.as_ref(),
|
||||||
|
monthly_rating_rows,
|
||||||
|
heatmap,
|
||||||
|
}
|
||||||
|
.render()
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
50
crates/adapters/template-askama/templates/activity_feed.html
Normal file
50
crates/adapters/template-askama/templates/activity_feed.html
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="diary">
|
||||||
|
{% for entry in entries %}
|
||||||
|
<article class="entry">
|
||||||
|
{% if let Some(poster) = entry.movie().poster_path() %}
|
||||||
|
<div class="poster">
|
||||||
|
<img src="/posters/{{ poster.value() }}" alt="">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="entry-body">
|
||||||
|
<div class="entry-title">
|
||||||
|
{{ entry.movie().title().value() }}
|
||||||
|
<span class="year">({{ entry.movie().release_year().value() }})</span>
|
||||||
|
</div>
|
||||||
|
{% if let Some(dir) = entry.movie().director() %}
|
||||||
|
<div class="director">{{ dir }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="rating">
|
||||||
|
{% for filled in entry.review().stars() %}
|
||||||
|
<span class="star {% if filled %}filled{% else %}empty{% endif %}">★</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% if let Some(comment) = entry.review().comment() %}
|
||||||
|
<div class="comment">{{ comment.value() }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="feed-meta">
|
||||||
|
<a href="/users/{{ entry.review().user_id().value() }}" class="feed-user">{{ entry.user_display_name() }}</a>
|
||||||
|
<span class="feed-time">{{ entry.review().watched_at().format("%b %-d, %Y") }}</span>
|
||||||
|
</div>
|
||||||
|
{% if ctx.is_current_user(entry.review().user_id().value()) %}
|
||||||
|
<form method="post" action="/reviews/{{ entry.review().id().value() }}/delete" class="delete-form">
|
||||||
|
<button type="submit">Delete</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% else %}
|
||||||
|
<p class="empty">No movies logged yet.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<nav class="pagination">
|
||||||
|
{% if current_offset >= limit %}
|
||||||
|
<a href="/?offset={{ current_offset - limit }}">← Prev</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if has_more %}
|
||||||
|
<a href="/?offset={{ current_offset + limit }}">Next →</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
{% endblock %}
|
||||||
@@ -13,6 +13,8 @@
|
|||||||
<header>
|
<header>
|
||||||
<a href="/" class="site-title">Movies Diary</a>
|
<a href="/" class="site-title">Movies Diary</a>
|
||||||
<nav>
|
<nav>
|
||||||
|
<a href="/">Feed</a>
|
||||||
|
<a href="/users">Users</a>
|
||||||
<a href="/feed.rss">RSS</a>
|
<a href="/feed.rss">RSS</a>
|
||||||
{% if let Some(email) = ctx.user_email %}
|
{% if let Some(email) = ctx.user_email %}
|
||||||
<a href="/reviews/new">Add Review</a>
|
<a href="/reviews/new">Add Review</a>
|
||||||
|
|||||||
164
crates/adapters/template-askama/templates/profile.html
Normal file
164
crates/adapters/template-askama/templates/profile.html
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="profile">
|
||||||
|
|
||||||
|
<div class="stats-header">
|
||||||
|
<div class="profile-name">{{ profile_display_name }}</div>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-tile">
|
||||||
|
<div class="stat-value">{{ stats.total_movies }}</div>
|
||||||
|
<div class="stat-label">movies</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-tile">
|
||||||
|
<div class="stat-value">{{ stats.avg_rating_display() }}★</div>
|
||||||
|
<div class="stat-label">avg rating</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-tile">
|
||||||
|
<div class="stat-value">{{ stats.favorite_director_display() }}</div>
|
||||||
|
<div class="stat-label">fav director</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-tile">
|
||||||
|
<div class="stat-value">{{ stats.most_active_month_display() }}</div>
|
||||||
|
<div class="stat-label">most active</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="view-tabs">
|
||||||
|
<a href="?view=recent" class="view-tab {% if view == "recent" %}active{% endif %}">Recent</a>
|
||||||
|
<a href="?view=ratings" class="view-tab {% if view == "ratings" %}active{% endif %}">Top Rated</a>
|
||||||
|
<a href="?view=history" class="view-tab {% if view == "history" %}active{% endif %}">History</a>
|
||||||
|
<a href="?view=trends" class="view-tab {% if view == "trends" %}active{% endif %}">Trends</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if view == "history" %}
|
||||||
|
{% if let Some(hist) = history %}
|
||||||
|
<div class="heatmap-section">
|
||||||
|
<div class="heatmap-label">Movies watched this year</div>
|
||||||
|
<div class="heatmap">
|
||||||
|
{% for cell in heatmap %}
|
||||||
|
<div class="heatmap-cell" style="background: rgba(74, 158, 255, {{ cell.alpha }})">
|
||||||
|
<div class="heatmap-count">{{ cell.count }}</div>
|
||||||
|
<div class="heatmap-month">{{ cell.month_label }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% for month in hist %}
|
||||||
|
<div class="history-month">
|
||||||
|
<h3 class="month-heading">{{ month.month_label }} <span class="month-count">{{ month.count }}</span></h3>
|
||||||
|
<div class="diary">
|
||||||
|
{% for entry in month.entries %}
|
||||||
|
<article class="entry">
|
||||||
|
{% if let Some(poster) = entry.movie().poster_path() %}
|
||||||
|
<div class="poster"><img src="/posters/{{ poster.value() }}" alt=""></div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="entry-body">
|
||||||
|
<div class="entry-title">{{ entry.movie().title().value() }} <span class="year">({{ entry.movie().release_year().value() }})</span></div>
|
||||||
|
{% if let Some(dir) = entry.movie().director() %}<div class="director">{{ dir }}</div>{% endif %}
|
||||||
|
<div class="rating">
|
||||||
|
{% for filled in entry.review().stars() %}
|
||||||
|
<span class="star {% if filled %}filled{% else %}empty{% endif %}">★</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="watched-at">{{ entry.review().watched_at().format("%b %-d") }}</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="empty">No movies logged yet.</p>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% elif view == "trends" %}
|
||||||
|
{% if let Some(t) = trends %}
|
||||||
|
<div class="trends-section">
|
||||||
|
{% if !monthly_rating_rows.is_empty() %}
|
||||||
|
<div class="chart-block">
|
||||||
|
<div class="chart-label">Average rating per month</div>
|
||||||
|
<div class="bar-chart">
|
||||||
|
{% for row in monthly_rating_rows %}
|
||||||
|
<div class="bar-col">
|
||||||
|
<div class="bar-fill" style="height: {{ row.bar_height_pct }}%"></div>
|
||||||
|
<div class="bar-month">{{ row.rating.month_label }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if !t.top_directors.is_empty() %}
|
||||||
|
<div class="chart-block">
|
||||||
|
<div class="chart-label">Most watched directors</div>
|
||||||
|
<div class="director-chart">
|
||||||
|
{% for d in t.top_directors %}
|
||||||
|
<div class="director-row">
|
||||||
|
<div class="director-name">{{ d.director }}</div>
|
||||||
|
<div class="director-bar">
|
||||||
|
{% if t.max_director_count > 0 %}
|
||||||
|
<div class="director-bar-fill" style="width: {{ d.count * 100 / t.max_director_count }}%"></div>
|
||||||
|
{% else %}
|
||||||
|
<div class="director-bar-fill" style="width: 0%"></div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="director-count">{{ d.count }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
{% if let Some(paged) = entries %}
|
||||||
|
<div class="diary">
|
||||||
|
{% for entry in paged.items %}
|
||||||
|
<article class="entry">
|
||||||
|
{% if let Some(poster) = entry.movie().poster_path() %}
|
||||||
|
<div class="poster">
|
||||||
|
<img src="/posters/{{ poster.value() }}" alt="">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="entry-body">
|
||||||
|
<div class="entry-title">
|
||||||
|
{{ entry.movie().title().value() }}
|
||||||
|
<span class="year">({{ entry.movie().release_year().value() }})</span>
|
||||||
|
</div>
|
||||||
|
{% if let Some(dir) = entry.movie().director() %}
|
||||||
|
<div class="director">{{ dir }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="rating">
|
||||||
|
{% for filled in entry.review().stars() %}
|
||||||
|
<span class="star {% if filled %}filled{% else %}empty{% endif %}">★</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% if let Some(comment) = entry.review().comment() %}
|
||||||
|
<div class="comment">{{ comment.value() }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="watched-at">{{ entry.review().watched_at().format("%Y-%m-%d") }}</div>
|
||||||
|
{% if ctx.is_current_user(entry.review().user_id().value()) %}
|
||||||
|
<form method="post" action="/reviews/{{ entry.review().id().value() }}/delete" class="delete-form">
|
||||||
|
<button type="submit">Delete</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% else %}
|
||||||
|
<p class="empty">No reviews yet.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<nav class="pagination">
|
||||||
|
{% if current_offset >= limit %}
|
||||||
|
<a href="?view={{ view }}&offset={{ current_offset - limit }}">← Prev</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if has_more %}
|
||||||
|
<a href="?view={{ view }}&offset={{ current_offset + limit }}">Next →</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
18
crates/adapters/template-askama/templates/users.html
Normal file
18
crates/adapters/template-askama/templates/users.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="users-list">
|
||||||
|
<h2 class="page-title">Members</h2>
|
||||||
|
{% for user in users %}
|
||||||
|
<div class="user-row">
|
||||||
|
<div class="user-avatar">{{ user.initial() }}</div>
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="user-name">{{ user.display_name() }}</div>
|
||||||
|
<div class="user-meta">{{ user.total_movies }} movies · avg {{ user.avg_rating_display() }}★</div>
|
||||||
|
</div>
|
||||||
|
<a href="/users/{{ user.user_id.value() }}" class="btn-secondary">View profile →</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="empty">No users yet.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -253,6 +253,10 @@ mod tests {
|
|||||||
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
|
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
|
||||||
panic!("unexpected")
|
panic!("unexpected")
|
||||||
}
|
}
|
||||||
|
async fn query_activity_feed(&self, _: &domain::models::collections::PageParams) -> Result<domain::models::collections::Paginated<domain::models::FeedEntry>, DomainError> { panic!("unexpected") }
|
||||||
|
async fn get_user_stats(&self, _: &domain::value_objects::UserId) -> Result<domain::models::UserStats, DomainError> { panic!("unexpected") }
|
||||||
|
async fn get_user_history(&self, _: &domain::value_objects::UserId) -> Result<Vec<domain::models::DiaryEntry>, DomainError> { panic!("unexpected") }
|
||||||
|
async fn get_user_trends(&self, _: &domain::value_objects::UserId) -> Result<domain::models::UserTrends, DomainError> { panic!("unexpected") }
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -300,6 +304,10 @@ mod tests {
|
|||||||
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
|
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
|
||||||
panic!("unexpected")
|
panic!("unexpected")
|
||||||
}
|
}
|
||||||
|
async fn query_activity_feed(&self, _: &domain::models::collections::PageParams) -> Result<domain::models::collections::Paginated<domain::models::FeedEntry>, DomainError> { panic!("unexpected") }
|
||||||
|
async fn get_user_stats(&self, _: &domain::value_objects::UserId) -> Result<domain::models::UserStats, DomainError> { panic!("unexpected") }
|
||||||
|
async fn get_user_history(&self, _: &domain::value_objects::UserId) -> Result<Vec<domain::models::DiaryEntry>, DomainError> { panic!("unexpected") }
|
||||||
|
async fn get_user_trends(&self, _: &domain::value_objects::UserId) -> Result<domain::models::UserTrends, DomainError> { panic!("unexpected") }
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -347,6 +355,10 @@ mod tests {
|
|||||||
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
|
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
|
||||||
panic!("unexpected")
|
panic!("unexpected")
|
||||||
}
|
}
|
||||||
|
async fn query_activity_feed(&self, _: &domain::models::collections::PageParams) -> Result<domain::models::collections::Paginated<domain::models::FeedEntry>, DomainError> { panic!("unexpected") }
|
||||||
|
async fn get_user_stats(&self, _: &domain::value_objects::UserId) -> Result<domain::models::UserStats, DomainError> { panic!("unexpected") }
|
||||||
|
async fn get_user_history(&self, _: &domain::value_objects::UserId) -> Result<Vec<domain::models::DiaryEntry>, DomainError> { panic!("unexpected") }
|
||||||
|
async fn get_user_trends(&self, _: &domain::value_objects::UserId) -> Result<domain::models::UserTrends, DomainError> { panic!("unexpected") }
|
||||||
}
|
}
|
||||||
|
|
||||||
struct MetaReturnsMovie(Movie);
|
struct MetaReturnsMovie(Movie);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use domain::models::{DiaryEntry, collections::Paginated};
|
use domain::models::{DiaryEntry, FeedEntry, MonthActivity, UserStats, UserSummary, UserTrends, collections::Paginated};
|
||||||
|
|
||||||
pub struct HtmlPageContext {
|
pub struct HtmlPageContext {
|
||||||
pub user_email: Option<String>,
|
pub user_email: Option<String>,
|
||||||
@@ -8,6 +8,12 @@ pub struct HtmlPageContext {
|
|||||||
pub register_enabled: bool,
|
pub register_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl HtmlPageContext {
|
||||||
|
pub fn is_current_user(&self, id: Uuid) -> bool {
|
||||||
|
self.user_id == Some(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct LoginPageData<'a> {
|
pub struct LoginPageData<'a> {
|
||||||
pub ctx: HtmlPageContext,
|
pub ctx: HtmlPageContext,
|
||||||
pub error: Option<&'a str>,
|
pub error: Option<&'a str>,
|
||||||
@@ -23,11 +29,41 @@ pub struct NewReviewPageData<'a> {
|
|||||||
pub error: Option<&'a str>,
|
pub error: Option<&'a str>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct ActivityFeedPageData {
|
||||||
|
pub ctx: HtmlPageContext,
|
||||||
|
pub entries: Paginated<FeedEntry>,
|
||||||
|
pub current_offset: u32,
|
||||||
|
pub has_more: bool,
|
||||||
|
pub limit: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct UsersPageData {
|
||||||
|
pub ctx: HtmlPageContext,
|
||||||
|
pub users: Vec<UserSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ProfilePageData {
|
||||||
|
pub ctx: HtmlPageContext,
|
||||||
|
pub profile_user_id: Uuid,
|
||||||
|
pub profile_user_email: String,
|
||||||
|
pub stats: UserStats,
|
||||||
|
pub view: String,
|
||||||
|
pub entries: Option<Paginated<DiaryEntry>>,
|
||||||
|
pub current_offset: u32,
|
||||||
|
pub has_more: bool,
|
||||||
|
pub limit: u32,
|
||||||
|
pub history: Option<Vec<MonthActivity>>,
|
||||||
|
pub trends: Option<UserTrends>,
|
||||||
|
}
|
||||||
|
|
||||||
pub trait HtmlRenderer: Send + Sync {
|
pub trait HtmlRenderer: Send + Sync {
|
||||||
fn render_diary_page(&self, data: &Paginated<DiaryEntry>, ctx: HtmlPageContext) -> Result<String, String>;
|
fn render_diary_page(&self, data: &Paginated<DiaryEntry>, ctx: HtmlPageContext) -> Result<String, String>;
|
||||||
fn render_login_page(&self, data: LoginPageData<'_>) -> Result<String, String>;
|
fn render_login_page(&self, data: LoginPageData<'_>) -> Result<String, String>;
|
||||||
fn render_register_page(&self, data: RegisterPageData<'_>) -> Result<String, String>;
|
fn render_register_page(&self, data: RegisterPageData<'_>) -> Result<String, String>;
|
||||||
fn render_new_review_page(&self, data: NewReviewPageData<'_>) -> Result<String, String>;
|
fn render_new_review_page(&self, data: NewReviewPageData<'_>) -> Result<String, String>;
|
||||||
|
fn render_activity_feed_page(&self, data: ActivityFeedPageData) -> Result<String, String>;
|
||||||
|
fn render_users_page(&self, data: UsersPageData) -> Result<String, String>;
|
||||||
|
fn render_profile_page(&self, data: ProfilePageData) -> Result<String, String>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait RssFeedRenderer: Send + Sync {
|
pub trait RssFeedRenderer: Send + Sync {
|
||||||
|
|||||||
@@ -11,3 +11,17 @@ pub struct GetDiaryQuery {
|
|||||||
pub struct GetReviewHistoryQuery {
|
pub struct GetReviewHistoryQuery {
|
||||||
pub movie_id: Uuid,
|
pub movie_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct GetActivityFeedQuery {
|
||||||
|
pub limit: Option<u32>,
|
||||||
|
pub offset: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct GetUsersQuery;
|
||||||
|
|
||||||
|
pub struct GetUserProfileQuery {
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub view: String,
|
||||||
|
pub limit: Option<u32>,
|
||||||
|
pub offset: Option<u32>,
|
||||||
|
}
|
||||||
|
|||||||
13
crates/application/src/use_cases/get_activity_feed.rs
Normal file
13
crates/application/src/use_cases/get_activity_feed.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::{FeedEntry, collections::{PageParams, Paginated}},
|
||||||
|
};
|
||||||
|
use crate::{context::AppContext, queries::GetActivityFeedQuery};
|
||||||
|
|
||||||
|
pub async fn execute(
|
||||||
|
ctx: &AppContext,
|
||||||
|
query: GetActivityFeedQuery,
|
||||||
|
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
|
let page = PageParams::new(query.limit, query.offset)?;
|
||||||
|
ctx.repository.query_activity_feed(&page).await
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ pub async fn execute(
|
|||||||
sort_by: query.sort_by.unwrap_or(SortDirection::Descending),
|
sort_by: query.sort_by.unwrap_or(SortDirection::Descending),
|
||||||
page,
|
page,
|
||||||
movie_id,
|
movie_id,
|
||||||
|
user_id: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let paginated_results = ctx.repository.query_diary(&filter).await?;
|
let paginated_results = ctx.repository.query_diary(&filter).await?;
|
||||||
|
|||||||
93
crates/application/src/use_cases/get_user_profile.rs
Normal file
93
crates/application/src/use_cases/get_user_profile.rs
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::{
|
||||||
|
DiaryEntry, DiaryFilter, MonthActivity, SortDirection, UserStats, UserTrends,
|
||||||
|
collections::{PageParams, Paginated},
|
||||||
|
},
|
||||||
|
value_objects::UserId,
|
||||||
|
};
|
||||||
|
use crate::{context::AppContext, queries::GetUserProfileQuery};
|
||||||
|
|
||||||
|
pub struct UserProfileData {
|
||||||
|
pub stats: UserStats,
|
||||||
|
pub entries: Option<Paginated<DiaryEntry>>,
|
||||||
|
pub history: Option<Vec<MonthActivity>>,
|
||||||
|
pub trends: Option<UserTrends>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute(
|
||||||
|
ctx: &AppContext,
|
||||||
|
query: GetUserProfileQuery,
|
||||||
|
) -> Result<UserProfileData, DomainError> {
|
||||||
|
let user_id = UserId::from_uuid(query.user_id);
|
||||||
|
let stats = ctx.repository.get_user_stats(&user_id).await?;
|
||||||
|
|
||||||
|
match query.view.as_str() {
|
||||||
|
"history" => {
|
||||||
|
// V1: loads all entries into memory. Personal diaries are bounded in size;
|
||||||
|
// spec calls for showing every movie grouped by month, so full load is intentional.
|
||||||
|
let all_entries = ctx.repository.get_user_history(&user_id).await?;
|
||||||
|
let history = group_by_month(all_entries);
|
||||||
|
Ok(UserProfileData { stats, entries: None, history: Some(history), trends: None })
|
||||||
|
}
|
||||||
|
"trends" => {
|
||||||
|
let trends = ctx.repository.get_user_trends(&user_id).await?;
|
||||||
|
Ok(UserProfileData { stats, entries: None, history: None, trends: Some(trends) })
|
||||||
|
}
|
||||||
|
"ratings" => {
|
||||||
|
let page = PageParams::new(query.limit, query.offset)?;
|
||||||
|
let filter = DiaryFilter {
|
||||||
|
sort_by: SortDirection::ByRatingDesc,
|
||||||
|
page,
|
||||||
|
movie_id: None,
|
||||||
|
user_id: Some(user_id),
|
||||||
|
};
|
||||||
|
let entries = ctx.repository.query_diary(&filter).await?;
|
||||||
|
Ok(UserProfileData { stats, entries: Some(entries), history: None, trends: None })
|
||||||
|
}
|
||||||
|
"recent" => {
|
||||||
|
let page = PageParams::new(query.limit, query.offset)?;
|
||||||
|
let filter = DiaryFilter {
|
||||||
|
sort_by: SortDirection::Descending,
|
||||||
|
page,
|
||||||
|
movie_id: None,
|
||||||
|
user_id: Some(user_id),
|
||||||
|
};
|
||||||
|
let entries = ctx.repository.query_diary(&filter).await?;
|
||||||
|
Ok(UserProfileData { stats, entries: Some(entries), history: None, trends: None })
|
||||||
|
}
|
||||||
|
other => Err(DomainError::ValidationError(format!("unknown view: {}", other))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn group_by_month(entries: Vec<DiaryEntry>) -> Vec<MonthActivity> {
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
let mut map: BTreeMap<String, Vec<DiaryEntry>> = BTreeMap::new();
|
||||||
|
for entry in entries {
|
||||||
|
let ym = entry.review().watched_at().format("%Y-%m").to_string();
|
||||||
|
map.entry(ym).or_default().push(entry);
|
||||||
|
}
|
||||||
|
let mut result: Vec<MonthActivity> = map
|
||||||
|
.into_iter()
|
||||||
|
.map(|(ym, entries)| MonthActivity {
|
||||||
|
month_label: format_year_month_long(&ym),
|
||||||
|
count: entries.len() as i64,
|
||||||
|
entries,
|
||||||
|
year_month: ym,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
result.reverse();
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_year_month_long(ym: &str) -> String {
|
||||||
|
let parts: Vec<&str> = ym.splitn(2, '-').collect();
|
||||||
|
if parts.len() != 2 { return ym.to_string(); }
|
||||||
|
let month = match parts[1] {
|
||||||
|
"01" => "January", "02" => "February", "03" => "March", "04" => "April",
|
||||||
|
"05" => "May", "06" => "June", "07" => "July", "08" => "August",
|
||||||
|
"09" => "September", "10" => "October", "11" => "November", "12" => "December",
|
||||||
|
_ => parts[1],
|
||||||
|
};
|
||||||
|
format!("{} {}", month, parts[0])
|
||||||
|
}
|
||||||
9
crates/application/src/use_cases/get_users.rs
Normal file
9
crates/application/src/use_cases/get_users.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
use domain::{errors::DomainError, models::UserSummary};
|
||||||
|
use crate::{context::AppContext, queries::GetUsersQuery};
|
||||||
|
|
||||||
|
pub async fn execute(
|
||||||
|
ctx: &AppContext,
|
||||||
|
_query: GetUsersQuery,
|
||||||
|
) -> Result<Vec<UserSummary>, DomainError> {
|
||||||
|
ctx.user_repository.list_with_stats().await
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
pub mod delete_review;
|
pub mod delete_review;
|
||||||
|
pub mod get_activity_feed;
|
||||||
pub mod get_diary;
|
pub mod get_diary;
|
||||||
pub mod get_review_history;
|
pub mod get_review_history;
|
||||||
|
pub mod get_user_profile;
|
||||||
|
pub mod get_users;
|
||||||
pub mod log_review;
|
pub mod log_review;
|
||||||
pub mod login;
|
pub mod login;
|
||||||
pub mod register;
|
pub mod register;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ pub enum SortDirection {
|
|||||||
#[default]
|
#[default]
|
||||||
Descending,
|
Descending,
|
||||||
Ascending,
|
Ascending,
|
||||||
|
ByRatingDesc,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
#[derive(Clone, Debug, Default)]
|
||||||
@@ -22,6 +23,7 @@ pub struct DiaryFilter {
|
|||||||
pub sort_by: SortDirection,
|
pub sort_by: SortDirection,
|
||||||
pub page: PageParams,
|
pub page: PageParams,
|
||||||
pub movie_id: Option<MovieId>,
|
pub movie_id: Option<MovieId>,
|
||||||
|
pub user_id: Option<UserId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -191,6 +193,11 @@ impl Review {
|
|||||||
pub fn created_at(&self) -> &NaiveDateTime {
|
pub fn created_at(&self) -> &NaiveDateTime {
|
||||||
&self.created_at
|
&self.created_at
|
||||||
}
|
}
|
||||||
|
/// Returns [star1_filled, star2_filled, ..., star5_filled]
|
||||||
|
pub fn stars(&self) -> [bool; 5] {
|
||||||
|
let r = self.rating.value();
|
||||||
|
[r >= 1, r >= 2, r >= 3, r >= 4, r >= 5]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -270,3 +277,90 @@ impl User {
|
|||||||
&self.password_hash
|
&self.password_hash
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct FeedEntry {
|
||||||
|
entry: DiaryEntry,
|
||||||
|
user_email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FeedEntry {
|
||||||
|
pub fn new(entry: DiaryEntry, user_email: String) -> Self {
|
||||||
|
Self { entry, user_email }
|
||||||
|
}
|
||||||
|
pub fn movie(&self) -> &Movie { self.entry.movie() }
|
||||||
|
pub fn review(&self) -> &Review { self.entry.review() }
|
||||||
|
pub fn user_email(&self) -> &str { &self.user_email }
|
||||||
|
pub fn user_display_name(&self) -> &str {
|
||||||
|
self.user_email.split('@').next().unwrap_or(&self.user_email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct UserSummary {
|
||||||
|
pub user_id: UserId,
|
||||||
|
pub email: String,
|
||||||
|
pub total_movies: i64,
|
||||||
|
pub avg_rating: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserSummary {
|
||||||
|
pub fn display_name(&self) -> &str {
|
||||||
|
self.email.split('@').next().unwrap_or(&self.email)
|
||||||
|
}
|
||||||
|
pub fn avg_rating_display(&self) -> String {
|
||||||
|
self.avg_rating.map(|r| format!("{:.1}", r)).unwrap_or_else(|| "—".to_string())
|
||||||
|
}
|
||||||
|
pub fn initial(&self) -> char {
|
||||||
|
self.display_name().chars().next().unwrap_or('?').to_ascii_uppercase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct UserStats {
|
||||||
|
pub total_movies: i64,
|
||||||
|
pub avg_rating: Option<f64>,
|
||||||
|
pub favorite_director: Option<String>,
|
||||||
|
pub most_active_month: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserStats {
|
||||||
|
pub fn avg_rating_display(&self) -> String {
|
||||||
|
self.avg_rating.map(|r| format!("{:.1}", r)).unwrap_or_else(|| "—".to_string())
|
||||||
|
}
|
||||||
|
pub fn favorite_director_display(&self) -> &str {
|
||||||
|
self.favorite_director.as_deref().unwrap_or("—")
|
||||||
|
}
|
||||||
|
pub fn most_active_month_display(&self) -> &str {
|
||||||
|
self.most_active_month.as_deref().unwrap_or("—")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct MonthActivity {
|
||||||
|
pub year_month: String,
|
||||||
|
pub month_label: String,
|
||||||
|
pub count: i64,
|
||||||
|
pub entries: Vec<DiaryEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct MonthlyRating {
|
||||||
|
pub year_month: String,
|
||||||
|
pub month_label: String,
|
||||||
|
pub avg_rating: f64,
|
||||||
|
pub count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct DirectorStat {
|
||||||
|
pub director: String,
|
||||||
|
pub count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct UserTrends {
|
||||||
|
pub monthly_ratings: Vec<MonthlyRating>,
|
||||||
|
pub top_directors: Vec<DirectorStat>,
|
||||||
|
pub max_director_count: i64,
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ use chrono::{DateTime, Utc};
|
|||||||
use crate::{
|
use crate::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::{DiaryEntry, DiaryFilter, Movie, Review, ReviewHistory, User, collections::Paginated},
|
models::{
|
||||||
|
DiaryEntry, DiaryFilter, FeedEntry, Movie, Review, ReviewHistory, User, UserStats,
|
||||||
|
UserTrends, UserSummary,
|
||||||
|
collections::{PageParams, Paginated},
|
||||||
|
},
|
||||||
value_objects::{
|
value_objects::{
|
||||||
Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterPath, PosterUrl,
|
Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterPath, PosterUrl,
|
||||||
ReleaseYear, ReviewId, UserId,
|
ReleaseYear, ReviewId, UserId,
|
||||||
@@ -38,6 +42,17 @@ pub trait MovieRepository: Send + Sync {
|
|||||||
async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError>;
|
async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError>;
|
||||||
|
|
||||||
async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError>;
|
async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError>;
|
||||||
|
|
||||||
|
async fn query_activity_feed(
|
||||||
|
&self,
|
||||||
|
page: &PageParams,
|
||||||
|
) -> Result<Paginated<FeedEntry>, DomainError>;
|
||||||
|
|
||||||
|
async fn get_user_stats(&self, user_id: &UserId) -> Result<UserStats, DomainError>;
|
||||||
|
|
||||||
|
async fn get_user_history(&self, user_id: &UserId) -> Result<Vec<DiaryEntry>, DomainError>;
|
||||||
|
|
||||||
|
async fn get_user_trends(&self, user_id: &UserId) -> Result<UserTrends, DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum MetadataSearchCriteria {
|
pub enum MetadataSearchCriteria {
|
||||||
@@ -89,6 +104,8 @@ pub trait UserRepository: Send + Sync {
|
|||||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
|
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
|
||||||
async fn save(&self, user: &User) -> Result<(), DomainError>;
|
async fn save(&self, user: &User) -> Result<(), DomainError>;
|
||||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError>;
|
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError>;
|
||||||
|
|
||||||
|
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|||||||
@@ -223,6 +223,13 @@ impl From<DiaryQueryParams> for GetDiaryQuery {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize, Default)]
|
||||||
|
pub struct ProfileQueryParams {
|
||||||
|
pub view: Option<String>,
|
||||||
|
pub limit: Option<u32>,
|
||||||
|
pub offset: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -102,6 +102,10 @@ mod tests {
|
|||||||
async fn get_review_by_id(&self, _: &ReviewId) -> Result<Option<Review>, DomainError> { panic!("unexpected") }
|
async fn get_review_by_id(&self, _: &ReviewId) -> Result<Option<Review>, DomainError> { panic!("unexpected") }
|
||||||
async fn delete_review(&self, _: &ReviewId) -> Result<(), DomainError> { panic!("unexpected") }
|
async fn delete_review(&self, _: &ReviewId) -> Result<(), DomainError> { panic!("unexpected") }
|
||||||
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") }
|
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") }
|
||||||
|
async fn query_activity_feed(&self, _: &domain::models::collections::PageParams) -> Result<domain::models::collections::Paginated<domain::models::FeedEntry>, DomainError> { panic!("unexpected") }
|
||||||
|
async fn get_user_stats(&self, _: &UserId) -> Result<domain::models::UserStats, DomainError> { panic!("unexpected") }
|
||||||
|
async fn get_user_history(&self, _: &UserId) -> Result<Vec<DiaryEntry>, DomainError> { panic!("unexpected") }
|
||||||
|
async fn get_user_trends(&self, _: &UserId) -> Result<domain::models::UserTrends, DomainError> { panic!("unexpected") }
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -138,6 +142,7 @@ mod tests {
|
|||||||
async fn find_by_email(&self, _: &Email) -> Result<Option<User>, DomainError> { panic!("unexpected") }
|
async fn find_by_email(&self, _: &Email) -> Result<Option<User>, DomainError> { panic!("unexpected") }
|
||||||
async fn save(&self, _: &User) -> Result<(), DomainError> { panic!("unexpected") }
|
async fn save(&self, _: &User) -> Result<(), DomainError> { panic!("unexpected") }
|
||||||
async fn find_by_id(&self, _: &domain::value_objects::UserId) -> Result<Option<User>, DomainError> { panic!("unexpected") }
|
async fn find_by_id(&self, _: &domain::value_objects::UserId) -> Result<Option<User>, DomainError> { panic!("unexpected") }
|
||||||
|
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> { panic!("unexpected") }
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|||||||
@@ -134,6 +134,10 @@ mod tests {
|
|||||||
async fn get_review_by_id(&self, _: &domain::value_objects::ReviewId) -> Result<Option<domain::models::Review>, domain::errors::DomainError> { panic!() }
|
async fn get_review_by_id(&self, _: &domain::value_objects::ReviewId) -> Result<Option<domain::models::Review>, domain::errors::DomainError> { panic!() }
|
||||||
async fn delete_review(&self, _: &domain::value_objects::ReviewId) -> Result<(), domain::errors::DomainError> { panic!() }
|
async fn delete_review(&self, _: &domain::value_objects::ReviewId) -> Result<(), domain::errors::DomainError> { panic!() }
|
||||||
async fn delete_movie(&self, _: &domain::value_objects::MovieId) -> Result<(), domain::errors::DomainError> { panic!() }
|
async fn delete_movie(&self, _: &domain::value_objects::MovieId) -> Result<(), domain::errors::DomainError> { panic!() }
|
||||||
|
async fn query_activity_feed(&self, _: &domain::models::collections::PageParams) -> Result<domain::models::collections::Paginated<domain::models::FeedEntry>, domain::errors::DomainError> { panic!() }
|
||||||
|
async fn get_user_stats(&self, _: &domain::value_objects::UserId) -> Result<domain::models::UserStats, domain::errors::DomainError> { panic!() }
|
||||||
|
async fn get_user_history(&self, _: &domain::value_objects::UserId) -> Result<Vec<domain::models::DiaryEntry>, domain::errors::DomainError> { panic!() }
|
||||||
|
async fn get_user_trends(&self, _: &domain::value_objects::UserId) -> Result<domain::models::UserTrends, domain::errors::DomainError> { panic!() }
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PanicRenderer;
|
struct PanicRenderer;
|
||||||
@@ -142,6 +146,9 @@ mod tests {
|
|||||||
fn render_login_page(&self, _: application::ports::LoginPageData<'_>) -> Result<String, String> { panic!() }
|
fn render_login_page(&self, _: application::ports::LoginPageData<'_>) -> Result<String, String> { panic!() }
|
||||||
fn render_register_page(&self, _: application::ports::RegisterPageData<'_>) -> Result<String, String> { panic!() }
|
fn render_register_page(&self, _: application::ports::RegisterPageData<'_>) -> Result<String, String> { panic!() }
|
||||||
fn render_new_review_page(&self, _: application::ports::NewReviewPageData<'_>) -> Result<String, String> { panic!() }
|
fn render_new_review_page(&self, _: application::ports::NewReviewPageData<'_>) -> Result<String, String> { panic!() }
|
||||||
|
fn render_activity_feed_page(&self, _: application::ports::ActivityFeedPageData) -> Result<String, String> { panic!() }
|
||||||
|
fn render_users_page(&self, _: application::ports::UsersPageData) -> Result<String, String> { panic!() }
|
||||||
|
fn render_profile_page(&self, _: application::ports::ProfilePageData) -> Result<String, String> { panic!() }
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PanicRssRenderer;
|
struct PanicRssRenderer;
|
||||||
@@ -156,7 +163,7 @@ mod tests {
|
|||||||
#[async_trait::async_trait] impl domain::ports::EventPublisher for PanicEvent { async fn publish(&self, _: &domain::events::DomainEvent) -> Result<(), domain::errors::DomainError> { panic!() } }
|
#[async_trait::async_trait] impl domain::ports::EventPublisher for PanicEvent { async fn publish(&self, _: &domain::events::DomainEvent) -> Result<(), domain::errors::DomainError> { panic!() } }
|
||||||
#[async_trait::async_trait] impl domain::ports::PasswordHasher for PanicHasher { async fn hash(&self, _: &str) -> Result<domain::value_objects::PasswordHash, domain::errors::DomainError> { panic!() } async fn verify(&self, _: &str, _: &domain::value_objects::PasswordHash) -> Result<bool, domain::errors::DomainError> { panic!() } }
|
#[async_trait::async_trait] impl domain::ports::PasswordHasher for PanicHasher { async fn hash(&self, _: &str) -> Result<domain::value_objects::PasswordHash, domain::errors::DomainError> { panic!() } async fn verify(&self, _: &str, _: &domain::value_objects::PasswordHash) -> Result<bool, domain::errors::DomainError> { panic!() } }
|
||||||
#[async_trait::async_trait] impl domain::ports::AuthService for PanicAuth { async fn generate_token(&self, _: &domain::value_objects::UserId) -> Result<domain::ports::GeneratedToken, domain::errors::DomainError> { panic!() } async fn validate_token(&self, _: &str) -> Result<domain::value_objects::UserId, domain::errors::DomainError> { panic!() } }
|
#[async_trait::async_trait] impl domain::ports::AuthService for PanicAuth { async fn generate_token(&self, _: &domain::value_objects::UserId) -> Result<domain::ports::GeneratedToken, domain::errors::DomainError> { panic!() } async fn validate_token(&self, _: &str) -> Result<domain::value_objects::UserId, domain::errors::DomainError> { panic!() } }
|
||||||
#[async_trait::async_trait] impl domain::ports::UserRepository for PanicUserRepo { async fn find_by_email(&self, _: &domain::value_objects::Email) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn save(&self, _: &domain::models::User) -> Result<(), domain::errors::DomainError> { panic!() } async fn find_by_id(&self, _: &domain::value_objects::UserId) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } }
|
#[async_trait::async_trait] impl domain::ports::UserRepository for PanicUserRepo { async fn find_by_email(&self, _: &domain::value_objects::Email) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn save(&self, _: &domain::models::User) -> Result<(), domain::errors::DomainError> { panic!() } async fn find_by_id(&self, _: &domain::value_objects::UserId) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, domain::errors::DomainError> { panic!() } }
|
||||||
|
|
||||||
let state = crate::state::AppState {
|
let state = crate::state::AppState {
|
||||||
app_ctx: AppContext {
|
app_ctx: AppContext {
|
||||||
@@ -237,6 +244,10 @@ mod tests {
|
|||||||
async fn get_review_by_id(&self, _: &domain::value_objects::ReviewId) -> Result<Option<domain::models::Review>, domain::errors::DomainError> { panic!() }
|
async fn get_review_by_id(&self, _: &domain::value_objects::ReviewId) -> Result<Option<domain::models::Review>, domain::errors::DomainError> { panic!() }
|
||||||
async fn delete_review(&self, _: &domain::value_objects::ReviewId) -> Result<(), domain::errors::DomainError> { panic!() }
|
async fn delete_review(&self, _: &domain::value_objects::ReviewId) -> Result<(), domain::errors::DomainError> { panic!() }
|
||||||
async fn delete_movie(&self, _: &domain::value_objects::MovieId) -> Result<(), domain::errors::DomainError> { panic!() }
|
async fn delete_movie(&self, _: &domain::value_objects::MovieId) -> Result<(), domain::errors::DomainError> { panic!() }
|
||||||
|
async fn query_activity_feed(&self, _: &domain::models::collections::PageParams) -> Result<domain::models::collections::Paginated<domain::models::FeedEntry>, domain::errors::DomainError> { panic!() }
|
||||||
|
async fn get_user_stats(&self, _: &domain::value_objects::UserId) -> Result<domain::models::UserStats, domain::errors::DomainError> { panic!() }
|
||||||
|
async fn get_user_history(&self, _: &domain::value_objects::UserId) -> Result<Vec<domain::models::DiaryEntry>, domain::errors::DomainError> { panic!() }
|
||||||
|
async fn get_user_trends(&self, _: &domain::value_objects::UserId) -> Result<domain::models::UserTrends, domain::errors::DomainError> { panic!() }
|
||||||
}
|
}
|
||||||
struct PanicMeta2; struct PanicFetcher2; struct PanicStorage2; struct PanicEvent2; struct PanicHasher2; struct PanicUserRepo2;
|
struct PanicMeta2; struct PanicFetcher2; struct PanicStorage2; struct PanicEvent2; struct PanicHasher2; struct PanicUserRepo2;
|
||||||
#[async_trait::async_trait] impl domain::ports::MetadataClient for PanicMeta2 { async fn fetch_movie_metadata(&self, _: &domain::ports::MetadataSearchCriteria) -> Result<domain::models::Movie, domain::errors::DomainError> { panic!() } async fn get_poster_url(&self, _: &domain::value_objects::ExternalMetadataId) -> Result<Option<domain::value_objects::PosterUrl>, domain::errors::DomainError> { panic!() } }
|
#[async_trait::async_trait] impl domain::ports::MetadataClient for PanicMeta2 { async fn fetch_movie_metadata(&self, _: &domain::ports::MetadataSearchCriteria) -> Result<domain::models::Movie, domain::errors::DomainError> { panic!() } async fn get_poster_url(&self, _: &domain::value_objects::ExternalMetadataId) -> Result<Option<domain::value_objects::PosterUrl>, domain::errors::DomainError> { panic!() } }
|
||||||
@@ -245,13 +256,16 @@ mod tests {
|
|||||||
#[async_trait::async_trait] impl domain::ports::EventPublisher for PanicEvent2 { async fn publish(&self, _: &domain::events::DomainEvent) -> Result<(), domain::errors::DomainError> { panic!() } }
|
#[async_trait::async_trait] impl domain::ports::EventPublisher for PanicEvent2 { async fn publish(&self, _: &domain::events::DomainEvent) -> Result<(), domain::errors::DomainError> { panic!() } }
|
||||||
#[async_trait::async_trait] impl domain::ports::PasswordHasher for PanicHasher2 { async fn hash(&self, _: &str) -> Result<domain::value_objects::PasswordHash, domain::errors::DomainError> { panic!() } async fn verify(&self, _: &str, _: &domain::value_objects::PasswordHash) -> Result<bool, domain::errors::DomainError> { panic!() } }
|
#[async_trait::async_trait] impl domain::ports::PasswordHasher for PanicHasher2 { async fn hash(&self, _: &str) -> Result<domain::value_objects::PasswordHash, domain::errors::DomainError> { panic!() } async fn verify(&self, _: &str, _: &domain::value_objects::PasswordHash) -> Result<bool, domain::errors::DomainError> { panic!() } }
|
||||||
#[async_trait::async_trait] impl domain::ports::AuthService for PanicAuth2 { async fn generate_token(&self, _: &domain::value_objects::UserId) -> Result<domain::ports::GeneratedToken, domain::errors::DomainError> { panic!() } async fn validate_token(&self, _: &str) -> Result<domain::value_objects::UserId, domain::errors::DomainError> { panic!() } }
|
#[async_trait::async_trait] impl domain::ports::AuthService for PanicAuth2 { async fn generate_token(&self, _: &domain::value_objects::UserId) -> Result<domain::ports::GeneratedToken, domain::errors::DomainError> { panic!() } async fn validate_token(&self, _: &str) -> Result<domain::value_objects::UserId, domain::errors::DomainError> { panic!() } }
|
||||||
#[async_trait::async_trait] impl domain::ports::UserRepository for PanicUserRepo2 { async fn find_by_email(&self, _: &domain::value_objects::Email) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn save(&self, _: &domain::models::User) -> Result<(), domain::errors::DomainError> { panic!() } async fn find_by_id(&self, _: &domain::value_objects::UserId) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } }
|
#[async_trait::async_trait] impl domain::ports::UserRepository for PanicUserRepo2 { async fn find_by_email(&self, _: &domain::value_objects::Email) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn save(&self, _: &domain::models::User) -> Result<(), domain::errors::DomainError> { panic!() } async fn find_by_id(&self, _: &domain::value_objects::UserId) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, domain::errors::DomainError> { panic!() } }
|
||||||
struct PanicRenderer2;
|
struct PanicRenderer2;
|
||||||
impl crate::ports::HtmlRenderer for PanicRenderer2 {
|
impl crate::ports::HtmlRenderer for PanicRenderer2 {
|
||||||
fn render_diary_page(&self, _: &domain::models::collections::Paginated<domain::models::DiaryEntry>, _: application::ports::HtmlPageContext) -> Result<String, String> { panic!() }
|
fn render_diary_page(&self, _: &domain::models::collections::Paginated<domain::models::DiaryEntry>, _: application::ports::HtmlPageContext) -> Result<String, String> { panic!() }
|
||||||
fn render_login_page(&self, _: application::ports::LoginPageData<'_>) -> Result<String, String> { panic!() }
|
fn render_login_page(&self, _: application::ports::LoginPageData<'_>) -> Result<String, String> { panic!() }
|
||||||
fn render_register_page(&self, _: application::ports::RegisterPageData<'_>) -> Result<String, String> { panic!() }
|
fn render_register_page(&self, _: application::ports::RegisterPageData<'_>) -> Result<String, String> { panic!() }
|
||||||
fn render_new_review_page(&self, _: application::ports::NewReviewPageData<'_>) -> Result<String, String> { panic!() }
|
fn render_new_review_page(&self, _: application::ports::NewReviewPageData<'_>) -> Result<String, String> { panic!() }
|
||||||
|
fn render_activity_feed_page(&self, _: application::ports::ActivityFeedPageData) -> Result<String, String> { panic!() }
|
||||||
|
fn render_users_page(&self, _: application::ports::UsersPageData) -> Result<String, String> { panic!() }
|
||||||
|
fn render_profile_page(&self, _: application::ports::ProfilePageData) -> Result<String, String> { panic!() }
|
||||||
}
|
}
|
||||||
struct PanicRssRenderer2;
|
struct PanicRssRenderer2;
|
||||||
impl crate::ports::RssFeedRenderer for PanicRssRenderer2 {
|
impl crate::ports::RssFeedRenderer for PanicRssRenderer2 {
|
||||||
@@ -291,6 +305,10 @@ mod tests {
|
|||||||
async fn get_review_by_id(&self, _: &domain::value_objects::ReviewId) -> Result<Option<domain::models::Review>, domain::errors::DomainError> { panic!() }
|
async fn get_review_by_id(&self, _: &domain::value_objects::ReviewId) -> Result<Option<domain::models::Review>, domain::errors::DomainError> { panic!() }
|
||||||
async fn delete_review(&self, _: &domain::value_objects::ReviewId) -> Result<(), domain::errors::DomainError> { panic!() }
|
async fn delete_review(&self, _: &domain::value_objects::ReviewId) -> Result<(), domain::errors::DomainError> { panic!() }
|
||||||
async fn delete_movie(&self, _: &domain::value_objects::MovieId) -> Result<(), domain::errors::DomainError> { panic!() }
|
async fn delete_movie(&self, _: &domain::value_objects::MovieId) -> Result<(), domain::errors::DomainError> { panic!() }
|
||||||
|
async fn query_activity_feed(&self, _: &domain::models::collections::PageParams) -> Result<domain::models::collections::Paginated<domain::models::FeedEntry>, domain::errors::DomainError> { panic!() }
|
||||||
|
async fn get_user_stats(&self, _: &domain::value_objects::UserId) -> Result<domain::models::UserStats, domain::errors::DomainError> { panic!() }
|
||||||
|
async fn get_user_history(&self, _: &domain::value_objects::UserId) -> Result<Vec<domain::models::DiaryEntry>, domain::errors::DomainError> { panic!() }
|
||||||
|
async fn get_user_trends(&self, _: &domain::value_objects::UserId) -> Result<domain::models::UserTrends, domain::errors::DomainError> { panic!() }
|
||||||
}
|
}
|
||||||
struct PanicMeta3; struct PanicFetcher3; struct PanicStorage3; struct PanicEvent3; struct PanicHasher3; struct PanicUserRepo3;
|
struct PanicMeta3; struct PanicFetcher3; struct PanicStorage3; struct PanicEvent3; struct PanicHasher3; struct PanicUserRepo3;
|
||||||
#[async_trait::async_trait] impl domain::ports::MetadataClient for PanicMeta3 { async fn fetch_movie_metadata(&self, _: &domain::ports::MetadataSearchCriteria) -> Result<domain::models::Movie, domain::errors::DomainError> { panic!() } async fn get_poster_url(&self, _: &domain::value_objects::ExternalMetadataId) -> Result<Option<domain::value_objects::PosterUrl>, domain::errors::DomainError> { panic!() } }
|
#[async_trait::async_trait] impl domain::ports::MetadataClient for PanicMeta3 { async fn fetch_movie_metadata(&self, _: &domain::ports::MetadataSearchCriteria) -> Result<domain::models::Movie, domain::errors::DomainError> { panic!() } async fn get_poster_url(&self, _: &domain::value_objects::ExternalMetadataId) -> Result<Option<domain::value_objects::PosterUrl>, domain::errors::DomainError> { panic!() } }
|
||||||
@@ -298,13 +316,16 @@ mod tests {
|
|||||||
#[async_trait::async_trait] impl domain::ports::PosterStorage for PanicStorage3 { async fn store_poster(&self, _: &domain::value_objects::MovieId, _: &[u8]) -> Result<domain::value_objects::PosterPath, domain::errors::DomainError> { panic!() } async fn get_poster(&self, _: &domain::value_objects::PosterPath) -> Result<Vec<u8>, domain::errors::DomainError> { panic!() } }
|
#[async_trait::async_trait] impl domain::ports::PosterStorage for PanicStorage3 { async fn store_poster(&self, _: &domain::value_objects::MovieId, _: &[u8]) -> Result<domain::value_objects::PosterPath, domain::errors::DomainError> { panic!() } async fn get_poster(&self, _: &domain::value_objects::PosterPath) -> Result<Vec<u8>, domain::errors::DomainError> { panic!() } }
|
||||||
#[async_trait::async_trait] impl domain::ports::EventPublisher for PanicEvent3 { async fn publish(&self, _: &domain::events::DomainEvent) -> Result<(), domain::errors::DomainError> { panic!() } }
|
#[async_trait::async_trait] impl domain::ports::EventPublisher for PanicEvent3 { async fn publish(&self, _: &domain::events::DomainEvent) -> Result<(), domain::errors::DomainError> { panic!() } }
|
||||||
#[async_trait::async_trait] impl domain::ports::PasswordHasher for PanicHasher3 { async fn hash(&self, _: &str) -> Result<domain::value_objects::PasswordHash, domain::errors::DomainError> { panic!() } async fn verify(&self, _: &str, _: &domain::value_objects::PasswordHash) -> Result<bool, domain::errors::DomainError> { panic!() } }
|
#[async_trait::async_trait] impl domain::ports::PasswordHasher for PanicHasher3 { async fn hash(&self, _: &str) -> Result<domain::value_objects::PasswordHash, domain::errors::DomainError> { panic!() } async fn verify(&self, _: &str, _: &domain::value_objects::PasswordHash) -> Result<bool, domain::errors::DomainError> { panic!() } }
|
||||||
#[async_trait::async_trait] impl domain::ports::UserRepository for PanicUserRepo3 { async fn find_by_email(&self, _: &domain::value_objects::Email) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn save(&self, _: &domain::models::User) -> Result<(), domain::errors::DomainError> { panic!() } async fn find_by_id(&self, _: &domain::value_objects::UserId) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } }
|
#[async_trait::async_trait] impl domain::ports::UserRepository for PanicUserRepo3 { async fn find_by_email(&self, _: &domain::value_objects::Email) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn save(&self, _: &domain::models::User) -> Result<(), domain::errors::DomainError> { panic!() } async fn find_by_id(&self, _: &domain::value_objects::UserId) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, domain::errors::DomainError> { panic!() } }
|
||||||
struct PanicRenderer3;
|
struct PanicRenderer3;
|
||||||
impl crate::ports::HtmlRenderer for PanicRenderer3 {
|
impl crate::ports::HtmlRenderer for PanicRenderer3 {
|
||||||
fn render_diary_page(&self, _: &domain::models::collections::Paginated<domain::models::DiaryEntry>, _: application::ports::HtmlPageContext) -> Result<String, String> { panic!() }
|
fn render_diary_page(&self, _: &domain::models::collections::Paginated<domain::models::DiaryEntry>, _: application::ports::HtmlPageContext) -> Result<String, String> { panic!() }
|
||||||
fn render_login_page(&self, _: application::ports::LoginPageData<'_>) -> Result<String, String> { panic!() }
|
fn render_login_page(&self, _: application::ports::LoginPageData<'_>) -> Result<String, String> { panic!() }
|
||||||
fn render_register_page(&self, _: application::ports::RegisterPageData<'_>) -> Result<String, String> { panic!() }
|
fn render_register_page(&self, _: application::ports::RegisterPageData<'_>) -> Result<String, String> { panic!() }
|
||||||
fn render_new_review_page(&self, _: application::ports::NewReviewPageData<'_>) -> Result<String, String> { panic!() }
|
fn render_new_review_page(&self, _: application::ports::NewReviewPageData<'_>) -> Result<String, String> { panic!() }
|
||||||
|
fn render_activity_feed_page(&self, _: application::ports::ActivityFeedPageData) -> Result<String, String> { panic!() }
|
||||||
|
fn render_users_page(&self, _: application::ports::UsersPageData) -> Result<String, String> { panic!() }
|
||||||
|
fn render_profile_page(&self, _: application::ports::ProfilePageData) -> Result<String, String> { panic!() }
|
||||||
}
|
}
|
||||||
struct PanicRssRenderer3;
|
struct PanicRssRenderer3;
|
||||||
impl crate::ports::RssFeedRenderer for PanicRssRenderer3 {
|
impl crate::ports::RssFeedRenderer for PanicRssRenderer3 {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
const DEFAULT_PAGE_LIMIT: u32 = 20;
|
||||||
|
|
||||||
pub mod html {
|
pub mod html {
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
@@ -11,13 +13,12 @@ pub mod html {
|
|||||||
use application::{
|
use application::{
|
||||||
commands::{DeleteReviewCommand, LoginCommand, RegisterCommand},
|
commands::{DeleteReviewCommand, LoginCommand, RegisterCommand},
|
||||||
ports::{HtmlPageContext, LoginPageData, NewReviewPageData, RegisterPageData},
|
ports::{HtmlPageContext, LoginPageData, NewReviewPageData, RegisterPageData},
|
||||||
use_cases::{delete_review, get_diary, log_review, login as login_uc, register as register_uc},
|
use_cases::{delete_review, log_review, login as login_uc, register as register_uc},
|
||||||
};
|
};
|
||||||
use domain::{errors::DomainError, value_objects::UserId};
|
use domain::{errors::DomainError, value_objects::UserId};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
dtos::{DiaryQueryParams, ErrorQuery, LoginForm, LogReviewData, LogReviewForm, RegisterForm},
|
dtos::{DiaryQueryParams, ErrorQuery, LoginForm, LogReviewData, LogReviewForm, RegisterForm},
|
||||||
errors::ApiError,
|
|
||||||
extractors::{OptionalCookieUser, RequiredCookieUser},
|
extractors::{OptionalCookieUser, RequiredCookieUser},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
@@ -58,21 +59,6 @@ pub mod html {
|
|||||||
(SET_COOKIE, HeaderValue::from_str(&val).expect("valid cookie"))
|
(SET_COOKIE, HeaderValue::from_str(&val).expect("valid cookie"))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_index(
|
|
||||||
OptionalCookieUser(user_id): OptionalCookieUser,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Query(params): Query<DiaryQueryParams>,
|
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
|
||||||
let query = params.into();
|
|
||||||
let ctx = build_page_context(&state, user_id).await;
|
|
||||||
let page = get_diary::execute(&state.app_ctx, query).await?;
|
|
||||||
let html = state
|
|
||||||
.html_renderer
|
|
||||||
.render_diary_page(&page, ctx)
|
|
||||||
.map_err(|e| ApiError(DomainError::InfrastructureError(e)))?;
|
|
||||||
Ok(Html(html))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_login_page(
|
pub async fn get_login_page(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(params): Query<ErrorQuery>,
|
Query(params): Query<ErrorQuery>,
|
||||||
@@ -235,6 +221,109 @@ pub mod html {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_activity_feed(
|
||||||
|
OptionalCookieUser(user_id): OptionalCookieUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(params): Query<DiaryQueryParams>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let ctx = build_page_context(&state, user_id).await;
|
||||||
|
let query = application::queries::GetActivityFeedQuery {
|
||||||
|
limit: params.limit,
|
||||||
|
offset: params.offset,
|
||||||
|
};
|
||||||
|
match application::use_cases::get_activity_feed::execute(&state.app_ctx, query).await {
|
||||||
|
Ok(entries) => {
|
||||||
|
let limit = entries.limit;
|
||||||
|
let offset = entries.offset;
|
||||||
|
let has_more = (offset as u64).saturating_add(limit as u64) < entries.total_count;
|
||||||
|
let data = application::ports::ActivityFeedPageData {
|
||||||
|
ctx,
|
||||||
|
current_offset: offset,
|
||||||
|
has_more,
|
||||||
|
limit,
|
||||||
|
entries,
|
||||||
|
};
|
||||||
|
match state.html_renderer.render_activity_feed_page(data) {
|
||||||
|
Ok(html) => Html(html).into_response(),
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_users_list(
|
||||||
|
OptionalCookieUser(user_id): OptionalCookieUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let ctx = build_page_context(&state, user_id).await;
|
||||||
|
match application::use_cases::get_users::execute(&state.app_ctx, application::queries::GetUsersQuery).await {
|
||||||
|
Ok(users) => {
|
||||||
|
let data = application::ports::UsersPageData { ctx, users };
|
||||||
|
match state.html_renderer.render_users_page(data) {
|
||||||
|
Ok(html) => Html(html).into_response(),
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_user_profile(
|
||||||
|
OptionalCookieUser(user_id): OptionalCookieUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(profile_user_uuid): Path<Uuid>,
|
||||||
|
Query(params): Query<crate::dtos::ProfileQueryParams>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let ctx = build_page_context(&state, user_id).await;
|
||||||
|
let view = params.view.unwrap_or_else(|| "recent".to_string());
|
||||||
|
|
||||||
|
let profile_user = match state.app_ctx.user_repository
|
||||||
|
.find_by_id(&domain::value_objects::UserId::from_uuid(profile_user_uuid))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(u)) => u,
|
||||||
|
Ok(None) => return (StatusCode::NOT_FOUND, "User not found").into_response(),
|
||||||
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = application::queries::GetUserProfileQuery {
|
||||||
|
user_id: profile_user_uuid,
|
||||||
|
view: view.clone(),
|
||||||
|
limit: params.limit,
|
||||||
|
offset: params.offset,
|
||||||
|
};
|
||||||
|
|
||||||
|
match application::use_cases::get_user_profile::execute(&state.app_ctx, query).await {
|
||||||
|
Ok(profile) => {
|
||||||
|
let (offset, has_more, limit) = profile.entries.as_ref()
|
||||||
|
.map(|e| {
|
||||||
|
let has_more = (e.offset as u64).saturating_add(e.limit as u64) < e.total_count;
|
||||||
|
(e.offset, has_more, e.limit)
|
||||||
|
})
|
||||||
|
.unwrap_or((0, false, super::DEFAULT_PAGE_LIMIT));
|
||||||
|
let data = application::ports::ProfilePageData {
|
||||||
|
ctx,
|
||||||
|
profile_user_id: profile_user_uuid,
|
||||||
|
profile_user_email: profile_user.email().value().to_string(),
|
||||||
|
stats: profile.stats,
|
||||||
|
view,
|
||||||
|
entries: profile.entries,
|
||||||
|
current_offset: offset,
|
||||||
|
has_more,
|
||||||
|
limit,
|
||||||
|
history: profile.history,
|
||||||
|
trends: profile.trends,
|
||||||
|
};
|
||||||
|
match state.html_renderer.render_profile_page(data) {
|
||||||
|
Ok(html) => Html(html).into_response(),
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub mod posters {
|
pub mod posters {
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
|
|
||||||
fn html_routes() -> Router<AppState> {
|
fn html_routes() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", routing::get(handlers::html::get_index))
|
.route("/", routing::get(handlers::html::get_activity_feed))
|
||||||
|
.route("/users", routing::get(handlers::html::get_users_list))
|
||||||
|
.route("/users/{id}", routing::get(handlers::html::get_user_profile))
|
||||||
.route(
|
.route(
|
||||||
"/login",
|
"/login",
|
||||||
routing::get(handlers::html::get_login_page)
|
routing::get(handlers::html::get_login_page)
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ impl UserRepository for NobodyUserRepo {
|
|||||||
async fn find_by_email(&self, _: &Email) -> Result<Option<User>, DomainError> { Ok(None) }
|
async fn find_by_email(&self, _: &Email) -> Result<Option<User>, DomainError> { Ok(None) }
|
||||||
async fn save(&self, _: &User) -> Result<(), DomainError> { panic!() }
|
async fn save(&self, _: &User) -> Result<(), DomainError> { panic!() }
|
||||||
async fn find_by_id(&self, _: &UserId) -> Result<Option<User>, DomainError> { panic!() }
|
async fn find_by_id(&self, _: &UserId) -> Result<Option<User>, DomainError> { panic!() }
|
||||||
|
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> { panic!() }
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn test_app() -> Router {
|
async fn test_app() -> Router {
|
||||||
|
|||||||
120
static/style.css
120
static/style.css
@@ -386,3 +386,123 @@ form button[type="submit"]:hover {
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Activity feed ---- */
|
||||||
|
.feed-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.feed-user {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.feed-user:hover { text-decoration: underline; }
|
||||||
|
.feed-time { opacity: 0.6; }
|
||||||
|
|
||||||
|
/* ---- Users list ---- */
|
||||||
|
.users-list { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||||
|
.page-title { font-size: 1.2rem; font-weight: 700; margin-bottom: 1rem; opacity: 0.9; }
|
||||||
|
.user-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
background: rgba(255,255,255,0.07);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
.user-avatar {
|
||||||
|
width: 40px; height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(74,158,255,0.2);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 1.1rem; font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.user-info { flex: 1; }
|
||||||
|
.user-name { font-weight: 600; font-size: 0.95rem; }
|
||||||
|
.user-meta { font-size: 0.8rem; opacity: 0.6; margin-top: 0.1rem; }
|
||||||
|
.btn-secondary {
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* ---- Profile stats header ---- */
|
||||||
|
.profile { display: flex; flex-direction: column; gap: 1rem; }
|
||||||
|
.stats-header {
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
}
|
||||||
|
.profile-name { font-size: 1.1rem; font-weight: 700; margin-bottom: 0.75rem; }
|
||||||
|
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.5rem; }
|
||||||
|
.stat-tile {
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.6rem 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.stat-value { font-size: 1.1rem; font-weight: 700; color: var(--primary); }
|
||||||
|
.stat-label { font-size: 0.7rem; opacity: 0.5; margin-top: 0.1rem; }
|
||||||
|
|
||||||
|
/* ---- View tabs ---- */
|
||||||
|
.view-tabs { display: flex; gap: 0.4rem; flex-wrap: wrap; }
|
||||||
|
.view-tab {
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: rgba(255,255,255,0.7);
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.view-tab:hover { background: rgba(255,255,255,0.14); }
|
||||||
|
.view-tab.active { background: var(--primary); color: #fff; font-weight: 600; }
|
||||||
|
|
||||||
|
/* ---- History heatmap ---- */
|
||||||
|
.heatmap-section { background: rgba(255,255,255,0.06); border-radius: 12px; padding: 1rem; }
|
||||||
|
.heatmap-label { font-size: 0.8rem; opacity: 0.5; margin-bottom: 0.6rem; }
|
||||||
|
.heatmap { display: grid; grid-template-columns: repeat(12, 1fr); gap: 4px; }
|
||||||
|
.heatmap-cell {
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.4rem 0.2rem;
|
||||||
|
text-align: center;
|
||||||
|
min-height: 48px;
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.heatmap-count { font-size: 0.85rem; font-weight: 700; }
|
||||||
|
.heatmap-month { font-size: 0.65rem; opacity: 0.6; margin-top: 2px; }
|
||||||
|
|
||||||
|
/* ---- History month sections ---- */
|
||||||
|
.history-month { margin-top: 1rem; }
|
||||||
|
.month-heading { font-size: 0.95rem; font-weight: 600; margin-bottom: 0.5rem; opacity: 0.8; }
|
||||||
|
.month-count { font-size: 0.8rem; opacity: 0.5; font-weight: 400; }
|
||||||
|
|
||||||
|
/* ---- Trends charts ---- */
|
||||||
|
.trends-section { display: flex; flex-direction: column; gap: 1.25rem; }
|
||||||
|
.chart-block { background: rgba(255,255,255,0.06); border-radius: 12px; padding: 1rem; }
|
||||||
|
.chart-label { font-size: 0.8rem; opacity: 0.5; margin-bottom: 0.75rem; }
|
||||||
|
|
||||||
|
.bar-chart {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
height: 80px;
|
||||||
|
}
|
||||||
|
.bar-col { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; gap: 3px; }
|
||||||
|
.bar-fill { width: 100%; background: var(--primary); border-radius: 3px 3px 0 0; min-height: 4px; opacity: 0.8; }
|
||||||
|
.bar-month { font-size: 0.65rem; opacity: 0.5; }
|
||||||
|
|
||||||
|
.director-chart { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.director-row { display: flex; align-items: center; gap: 0.6rem; }
|
||||||
|
.director-name { font-size: 0.85rem; width: 140px; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.director-bar { flex: 1; background: rgba(255,255,255,0.08); border-radius: 4px; height: 10px; overflow: hidden; }
|
||||||
|
.director-bar-fill { height: 100%; background: var(--primary); border-radius: 4px; opacity: 0.8; }
|
||||||
|
.director-count { font-size: 0.8rem; opacity: 0.5; width: 20px; text-align: right; }
|
||||||
|
|||||||
Reference in New Issue
Block a user