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 = [
|
||||
"application",
|
||||
"askama",
|
||||
"chrono",
|
||||
"domain",
|
||||
"serde",
|
||||
]
|
||||
|
||||
@@ -3,11 +3,12 @@ use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{
|
||||
DiaryEntry, DiaryFilter, Movie, Review, ReviewHistory, SortDirection,
|
||||
collections::Paginated,
|
||||
DiaryEntry, DiaryFilter, DirectorStat, FeedEntry, Movie, MonthlyRating,
|
||||
Review, ReviewHistory, SortDirection, UserStats, UserTrends,
|
||||
collections::{PageParams, Paginated},
|
||||
},
|
||||
ports::MovieRepository,
|
||||
value_objects::{ExternalMetadataId, MovieId, MovieTitle, ReleaseYear, ReviewId},
|
||||
value_objects::{ExternalMetadataId, MovieId, MovieTitle, ReleaseYear, ReviewId, UserId},
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
@@ -15,10 +16,26 @@ mod migrations;
|
||||
mod models;
|
||||
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;
|
||||
|
||||
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 {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
@@ -59,7 +76,8 @@ impl SqliteMovieRepository {
|
||||
offset: i64,
|
||||
) -> Result<Vec<DiaryRow>, DomainError> {
|
||||
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,
|
||||
"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
|
||||
@@ -99,7 +117,8 @@ impl SqliteMovieRepository {
|
||||
offset: i64,
|
||||
) -> Result<Vec<DiaryRow>, DomainError> {
|
||||
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,
|
||||
"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
|
||||
@@ -134,6 +153,141 @@ impl SqliteMovieRepository {
|
||||
.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]
|
||||
@@ -261,18 +415,36 @@ impl MovieRepository for SqliteMovieRepository {
|
||||
let limit = filter.page.limit as i64;
|
||||
let offset = filter.page.offset as i64;
|
||||
|
||||
let (total, rows) = match &filter.movie_id {
|
||||
None => tokio::try_join!(
|
||||
let (total, rows) = match (&filter.movie_id, &filter.user_id) {
|
||||
(None, None) => tokio::try_join!(
|
||||
self.count_diary_entries(None),
|
||||
self.fetch_all_diary_rows(&filter.sort_by, limit, offset)
|
||||
)?,
|
||||
Some(id) => {
|
||||
(Some(id), None) => {
|
||||
let id_str = id.value().to_string();
|
||||
tokio::try_join!(
|
||||
self.count_diary_entries(Some(id_str.as_str())),
|
||||
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
|
||||
@@ -351,4 +523,119 @@ impl MovieRepository for SqliteMovieRepository {
|
||||
|
||||
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 domain::{
|
||||
errors::DomainError,
|
||||
models::{DiaryEntry, Movie, Review},
|
||||
models::{DiaryEntry, FeedEntry, Movie, Review, UserSummary},
|
||||
value_objects::{
|
||||
Comment, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
|
||||
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> {
|
||||
Uuid::parse_str(s)
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Invalid UUID '{}': {}", s, e)))
|
||||
|
||||
@@ -8,6 +8,7 @@ use domain::{
|
||||
ports::UserRepository,
|
||||
value_objects::{Email, PasswordHash, UserId},
|
||||
};
|
||||
use super::models::UserSummaryRow;
|
||||
|
||||
pub struct SqliteUserRepository {
|
||||
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)]
|
||||
|
||||
@@ -7,6 +7,7 @@ edition = "2024"
|
||||
askama = { version = "0.16.0" }
|
||||
|
||||
serde = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
domain = { workspace = true }
|
||||
application = { workspace = true }
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
use askama::Template;
|
||||
use chrono::Datelike;
|
||||
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)]
|
||||
#[template(path = "diary.html")]
|
||||
@@ -35,6 +40,95 @@ struct NewReviewTemplate<'a> {
|
||||
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;
|
||||
|
||||
impl AskamaHtmlRenderer {
|
||||
@@ -83,4 +177,55 @@ impl HtmlRenderer for AskamaHtmlRenderer {
|
||||
.render()
|
||||
.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>
|
||||
<a href="/" class="site-title">Movies Diary</a>
|
||||
<nav>
|
||||
<a href="/">Feed</a>
|
||||
<a href="/users">Users</a>
|
||||
<a href="/feed.rss">RSS</a>
|
||||
{% if let Some(email) = ctx.user_email %}
|
||||
<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> {
|
||||
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]
|
||||
@@ -300,6 +304,10 @@ mod tests {
|
||||
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, _: &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]
|
||||
@@ -347,6 +355,10 @@ mod tests {
|
||||
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, _: &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);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::models::{DiaryEntry, collections::Paginated};
|
||||
use domain::models::{DiaryEntry, FeedEntry, MonthActivity, UserStats, UserSummary, UserTrends, collections::Paginated};
|
||||
|
||||
pub struct HtmlPageContext {
|
||||
pub user_email: Option<String>,
|
||||
@@ -8,6 +8,12 @@ pub struct HtmlPageContext {
|
||||
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 ctx: HtmlPageContext,
|
||||
pub error: Option<&'a str>,
|
||||
@@ -23,11 +29,41 @@ pub struct NewReviewPageData<'a> {
|
||||
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 {
|
||||
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_register_page(&self, data: RegisterPageData<'_>) -> 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 {
|
||||
|
||||
@@ -11,3 +11,17 @@ pub struct GetDiaryQuery {
|
||||
pub struct GetReviewHistoryQuery {
|
||||
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),
|
||||
page,
|
||||
movie_id,
|
||||
user_id: None,
|
||||
};
|
||||
|
||||
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 get_activity_feed;
|
||||
pub mod get_diary;
|
||||
pub mod get_review_history;
|
||||
pub mod get_user_profile;
|
||||
pub mod get_users;
|
||||
pub mod log_review;
|
||||
pub mod login;
|
||||
pub mod register;
|
||||
|
||||
@@ -15,6 +15,7 @@ pub enum SortDirection {
|
||||
#[default]
|
||||
Descending,
|
||||
Ascending,
|
||||
ByRatingDesc,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -22,6 +23,7 @@ pub struct DiaryFilter {
|
||||
pub sort_by: SortDirection,
|
||||
pub page: PageParams,
|
||||
pub movie_id: Option<MovieId>,
|
||||
pub user_id: Option<UserId>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -191,6 +193,11 @@ impl Review {
|
||||
pub fn created_at(&self) -> &NaiveDateTime {
|
||||
&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)]
|
||||
@@ -270,3 +277,90 @@ impl User {
|
||||
&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::{
|
||||
errors::DomainError,
|
||||
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::{
|
||||
Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterPath, PosterUrl,
|
||||
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_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 {
|
||||
@@ -89,6 +104,8 @@ pub trait UserRepository: Send + Sync {
|
||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
|
||||
async fn save(&self, user: &User) -> Result<(), 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]
|
||||
|
||||
@@ -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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -102,6 +102,10 @@ mod tests {
|
||||
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_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]
|
||||
@@ -138,6 +142,7 @@ mod tests {
|
||||
async fn find_by_email(&self, _: &Email) -> Result<Option<User>, 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 list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> { panic!("unexpected") }
|
||||
}
|
||||
|
||||
#[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 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 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;
|
||||
@@ -142,6 +146,9 @@ mod tests {
|
||||
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_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;
|
||||
@@ -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::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::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 {
|
||||
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 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 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;
|
||||
#[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::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::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;
|
||||
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_login_page(&self, _: application::ports::LoginPageData<'_>) -> 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_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;
|
||||
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 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 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;
|
||||
#[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::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::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;
|
||||
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_login_page(&self, _: application::ports::LoginPageData<'_>) -> 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_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;
|
||||
impl crate::ports::RssFeedRenderer for PanicRssRenderer3 {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
const DEFAULT_PAGE_LIMIT: u32 = 20;
|
||||
|
||||
pub mod html {
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
@@ -11,13 +13,12 @@ pub mod html {
|
||||
use application::{
|
||||
commands::{DeleteReviewCommand, LoginCommand, RegisterCommand},
|
||||
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 crate::{
|
||||
dtos::{DiaryQueryParams, ErrorQuery, LoginForm, LogReviewData, LogReviewForm, RegisterForm},
|
||||
errors::ApiError,
|
||||
extractors::{OptionalCookieUser, RequiredCookieUser},
|
||||
state::AppState,
|
||||
};
|
||||
@@ -58,21 +59,6 @@ pub mod html {
|
||||
(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(
|
||||
State(state): State<AppState>,
|
||||
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 {
|
||||
|
||||
@@ -14,7 +14,9 @@ pub fn build_router(state: AppState) -> Router {
|
||||
|
||||
fn html_routes() -> Router<AppState> {
|
||||
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(
|
||||
"/login",
|
||||
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 save(&self, _: &User) -> Result<(), 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 {
|
||||
|
||||
120
static/style.css
120
static/style.css
@@ -386,3 +386,123 @@ form button[type="submit"]:hover {
|
||||
margin-bottom: 16px;
|
||||
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