This commit is contained in:
Cat /dev/Nulo 2024-08-04 14:38:59 -03:00
parent 4bf1351688
commit d38b2a8cb0
8 changed files with 89 additions and 25 deletions

1
rust/Cargo.lock generated
View file

@ -353,6 +353,7 @@ dependencies = [
"iana-time-zone", "iana-time-zone",
"js-sys", "js-sys",
"num-traits", "num-traits",
"serde",
"wasm-bindgen", "wasm-bindgen",
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]

View file

@ -9,7 +9,7 @@ edition = "2021"
again = "0.1.2" again = "0.1.2"
anyhow = "1.0.79" anyhow = "1.0.79"
base64 = "0.21.7" base64 = "0.21.7"
chrono = "0.4" chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4.4.15", features = ["derive"] } clap = { version = "4.4.15", features = ["derive"] }
cron = "0.12.0" cron = "0.12.0"
sqlx = { version = "0.8", features = [ "runtime-tokio", "sqlite", "chrono", "json" ] } sqlx = { version = "0.8", features = [ "runtime-tokio", "sqlite", "chrono", "json" ] }

View file

@ -1,4 +1,11 @@
use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::get, Json, Router}; use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
routing::get,
Json, Router,
};
use chrono::{DateTime, Utc};
use clap::ValueEnum; use clap::ValueEnum;
use futures::future::join_all; use futures::future::join_all;
use itertools::Itertools; use itertools::Itertools;
@ -168,6 +175,45 @@ async fn get_best_selling(State(pool): State<SqlitePool>) -> impl IntoResponse {
Json(categories_with_products) Json(categories_with_products)
} }
async fn get_product_history(
State(pool): State<SqlitePool>,
Path(ean): Path<String>,
) -> impl IntoResponse {
#[derive(sqlx::FromRow, Debug, Serialize)]
struct Precio {
ean: String,
fetched_at: chrono::DateTime<Utc>,
precio_centavos: Option<i64>,
in_stock: Option<bool>,
url: String,
name: Option<String>,
image_url: Option<String>,
}
let precios = sqlx::query!(
"
select ean,fetched_at,precio_centavos,in_stock,url,name,image_url from precios
where ean = ?
order by fetched_at
",
ean
)
.map(|r| Precio {
ean: r.ean,
url: r.url,
fetched_at: DateTime::from_timestamp(r.fetched_at, 0).unwrap(),
image_url: r.image_url,
name: r.name,
in_stock: r.in_stock.map(|x| x == 1),
precio_centavos: r.precio_centavos,
})
.fetch_all(&pool)
.await
.unwrap();
Json(precios)
}
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
@ -205,6 +251,7 @@ async fn main() {
.route("/", get(index)) .route("/", get(index))
.route("/api/healthcheck", get(healthcheck)) .route("/api/healthcheck", get(healthcheck))
.route("/api/0/best-selling-products", get(get_best_selling)) .route("/api/0/best-selling-products", get(get_best_selling))
.route("/api/0/ean/:ean/history", get(get_product_history))
.with_state(pool); .with_state(pool);
let listener = tokio::net::TcpListener::bind("0.0.0.0:8000").await.unwrap(); let listener = tokio::net::TcpListener::bind("0.0.0.0:8000").await.unwrap();

View file

@ -1 +1,2 @@
// place files you want to import through the `$lib` alias in this folder. // place files you want to import through the `$lib` alias in this folder.
export const API_HOST = import.meta.env.VITE_API_HOST;

View file

@ -1,20 +1,23 @@
import { error } from "@sveltejs/kit"; import { error } from "@sveltejs/kit";
import { eq } from "drizzle-orm";
import type { PageServerLoad } from "./$types"; import type { PageServerLoad } from "./$types";
import { getDb, schema } from "$lib/server/db"; import { z } from "zod";
const { precios } = schema; import { zPrecio, type Precio } from "./common";
import { API_HOST } from "$lib";
async function getProductHistory(ean: string) {
const res = await fetch(`${API_HOST}/api/0/ean/${ean}/history`);
const json = await res.json();
return z.array(zPrecio).parse(json);
}
export const load: PageServerLoad = async ({ params }) => { export const load: PageServerLoad = async ({ params }) => {
const db = await getDb(); const res = await getProductHistory(params.ean);
const q = db
.select()
.from(precios)
.where(eq(precios.ean, params.ean))
.orderBy(precios.fetchedAt);
const res = await q;
if (res.length === 0) return error(404, "Not Found"); if (res.length === 0) return error(404, "Not Found");
const meta = res.findLast((p) => p.name); const meta = res.findLast(
(p): p is Precio & { name: string; image_url: string } =>
!!(p.name && p.image_url),
);
return { precios: res, meta }; return { precios: res, meta };
}; };

View file

@ -1,18 +1,18 @@
<script lang="ts"> <script lang="ts">
import { Supermercado, hosts } from "db-datos/supermercado"; import { Supermercado, hosts } from "db-datos/supermercado";
import * as schema from "db-datos/schema";
import type { PageData } from "./$types"; import type { PageData } from "./$types";
import Chart from "./Chart.svelte"; import Chart from "./Chart.svelte";
import type { Precio } from "./common";
export let data: PageData; export let data: PageData;
let urls: Map<Supermercado, schema.Precio>; let urls: Map<Supermercado, Precio>;
$: urls = data.precios.reduce((prev, curr) => { $: urls = data.precios.reduce((prev, curr) => {
const url = new URL(curr.url); const url = new URL(curr.url);
const supermercado = hosts[url.hostname]; const supermercado = hosts[url.hostname];
prev.set(supermercado, curr); prev.set(supermercado, curr);
return prev; return prev;
}, new Map<Supermercado, schema.Precio>()); }, new Map<Supermercado, Precio>());
const classBySupermercado: { [supermercado in Supermercado]: string } = { const classBySupermercado: { [supermercado in Supermercado]: string } = {
[Supermercado.Dia]: "bg-[#d52b1e] focus:ring-[#d52b1e]", [Supermercado.Dia]: "bg-[#d52b1e] focus:ring-[#d52b1e]",
@ -30,18 +30,18 @@
{#if data.meta} {#if data.meta}
<h1 class="text-3xl font-bold">{data.meta.name}</h1> <h1 class="text-3xl font-bold">{data.meta.name}</h1>
<img src={data.meta.imageUrl} alt={data.meta.name} class="max-h-48" /> <img src={data.meta.image_url} alt={data.meta.name} class="max-h-48" />
<div class="flex gap-2"> <div class="flex gap-2">
{#each urls as [supermercado, { url, precioCentavos }]} {#each urls as [supermercado, { url, precio_centavos }]}
<a <a
href={url} href={url}
rel="noreferrer noopener" rel="noreferrer noopener"
target="_blank" target="_blank"
class={`focus:shadow-outline inline-flex flex-col items-center justify-center rounded-md ${classBySupermercado[supermercado]} px-4 py-2 font-medium tracking-wide text-white transition-colors duration-200 hover:bg-opacity-80 focus:outline-none focus:ring-2 focus:ring-offset-2`} class={`focus:shadow-outline inline-flex flex-col items-center justify-center rounded-md ${classBySupermercado[supermercado]} px-4 py-2 font-medium tracking-wide text-white transition-colors duration-200 hover:bg-opacity-80 focus:outline-none focus:ring-2 focus:ring-offset-2`}
> >
{#if precioCentavos} {#if precio_centavos}
<span class="text-lg font-bold" <span class="text-lg font-bold"
>{formatter.format(precioCentavos / 100)}</span >{formatter.format(precio_centavos / 100)}</span
> >
{/if} {/if}
<span class="text-sm">{supermercado}</span> <span class="text-sm">{supermercado}</span>

View file

@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { Precio } from "db-datos/schema";
// import dayjs from "dayjs"; // import dayjs from "dayjs";
import ChartJs from "./ChartJs.svelte"; import ChartJs from "./ChartJs.svelte";
import { hosts, colorBySupermercado } from "db-datos/supermercado"; import { hosts, colorBySupermercado } from "db-datos/supermercado";
import type { Precio } from "./common";
export let precios: Precio[]; export let precios: Precio[];
@ -15,15 +15,15 @@
const ps = precios const ps = precios
.filter((p) => new URL(p.url!).hostname === host) .filter((p) => new URL(p.url!).hostname === host)
.filter( .filter(
(p): p is Precio & { precioCentavos: number } => (p): p is Precio & { precio_centavos: number } =>
p.precioCentavos !== null, p.precio_centavos !== null,
); );
return { return {
label: supermercado, label: supermercado,
data: [ data: [
...ps.map((p) => ({ ...ps.map((p) => ({
x: p.fetchedAt, x: p.fetched_at,
y: p.precioCentavos / 100, y: p.precio_centavos / 100,
})), })),
// lie // lie
// ...ps.map((p) => ({ // ...ps.map((p) => ({

View file

@ -0,0 +1,12 @@
import { z } from "zod";
export const zPrecio = z.object({
ean: z.string(),
fetched_at: z.coerce.date(),
precio_centavos: z.number().nullable(),
in_stock: z.boolean().nullable(),
url: z.string(),
name: z.string().nullable(),
image_url: z.string().nullable(),
});
export type Precio = z.infer<typeof zPrecio>;