From 7a90ced98efcf1b068285febb568ca63048267ea Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Wed, 17 Jun 2026 16:39:43 +0200 Subject: [PATCH] updated wording --- backend/migrations/0005_currency_override.sql | 6 + backend/src/notify.rs | 77 ++++++++-- backend/src/routes/lists.rs | 50 +++++-- backend/src/worker.rs | 6 +- frontend/src/lib/lists.svelte.ts | 1 + frontend/src/routes/+layout.svelte | 7 +- frontend/src/routes/+page.svelte | 21 ++- frontend/src/routes/forgot/+page.svelte | 2 +- frontend/src/routes/lists/+page.svelte | 12 +- frontend/src/routes/lists/[id]/+page.svelte | 134 +++++++++++++++--- frontend/src/routes/login/+page.svelte | 2 +- frontend/src/routes/register/+page.svelte | 6 +- frontend/src/routes/reset/+page.svelte | 4 +- frontend/src/routes/settings/+page.svelte | 8 +- 14 files changed, 257 insertions(+), 79 deletions(-) create mode 100644 backend/migrations/0005_currency_override.sql diff --git a/backend/migrations/0005_currency_override.sql b/backend/migrations/0005_currency_override.sql new file mode 100644 index 0000000..30e9f03 --- /dev/null +++ b/backend/migrations/0005_currency_override.sql @@ -0,0 +1,6 @@ +-- Manual currency override for items whose auto-detected currency is wrong. +-- Some Shopify shops price an item identically in base and presentment currency +-- (e.g. $49.90 == €49.90), which makes the conversion heuristic mislabel it. +-- When set, this wins over the fetched currency on every refetch and display. +ALTER TABLE items + ADD COLUMN currency_override TEXT; diff --git a/backend/src/notify.rs b/backend/src/notify.rs index 4889bce..01f876d 100644 --- a/backend/src/notify.rs +++ b/backend/src/notify.rs @@ -1,12 +1,15 @@ //! Phase 4: price-drop notifications. //! -//! After a successful refetch we check whether an item's watched price has -//! fallen to or below the owner's target. If so — and we haven't already told -//! them about this drop — we email them in the house gospel voice. The -//! `items.notified_at` column is the de-dupe latch: -//! - `NULL` = armed; a drop to/under target fires one email and stamps `now()`. -//! - non-NULL = already announced; stays quiet until the price rises back -//! above target, which clears the latch (re-arms) for the next drop. +//! After a successful refetch we email the owner about a price drop. Two modes: +//! - **Target set:** notify when the price falls to/under the target. The +//! `items.notified_at` column is the de-dupe latch: +//! - `NULL` = armed; a drop to/under target fires one email and stamps +//! `now()`. +//! - non-NULL = already announced; stays quiet until the price rises back +//! above target, which clears the latch (re-arms) for the next drop. +//! - **No target:** notify on *any* drop — whenever this fetch's price is +//! lower than the immediately preceding observation in `price_history`. +//! No latch needed: a stable price has no preceding-higher observation. use rust_decimal::Decimal; use uuid::Uuid; @@ -55,17 +58,33 @@ async fn run(state: &AppState, item_id: Uuid) -> anyhow::Result<()> { let Some(row) = row else { return Ok(()) }; - // Need both a watched price and a target to judge a drop. - let (Some(price), Some(target)) = (row.current_price, row.target_price) else { + // Need a current price to judge anything. + let Some(price) = row.current_price else { return Ok(()); }; - let on_sale = price <= target; + match row.target_price { + // Target set → notify at/under target, latched against repeats. + Some(target) => target_drop(state, item_id, &row, price, target).await, + // No target → notify on any drop versus the previous observation. + None => any_drop(state, item_id, &row, price).await, + } +} + +/// Target mode: email once when the price reaches the target; re-arm above it. +async fn target_drop( + state: &AppState, + item_id: Uuid, + row: &NotifyRow, + price: Decimal, + target: Decimal, +) -> anyhow::Result<()> { + let on_sale = price <= target; match (on_sale, row.notified_at.is_some()) { // Reached target and not yet announced → email + latch. (true, false) => { if row.notify_email { - send(state, &row, price, target).await?; + send(state, row, price, Some(target)).await?; } // Latch even if the user has email off, so flipping it on later // doesn't replay an old drop. Re-arms when price climbs back up. @@ -86,16 +105,44 @@ async fn run(state: &AppState, item_id: Uuid) -> anyhow::Result<()> { Ok(()) } +/// No-target mode: email whenever this fetch is cheaper than the one before it. +/// The newest `price_history` row is this fetch (inserted just before notify), +/// so the previous observation is the second-newest. +async fn any_drop( + state: &AppState, + item_id: Uuid, + row: &NotifyRow, + price: Decimal, +) -> anyhow::Result<()> { + let prev: Option = sqlx::query_scalar( + "SELECT price FROM price_history + WHERE item_id = $1 ORDER BY fetched_at DESC OFFSET 1 LIMIT 1", + ) + .bind(item_id) + .fetch_optional(&state.pool) + .await?; + + // A drop is a strictly lower price than the previous observation. + if matches!(prev, Some(p) if price < p) && row.notify_email { + send(state, row, price, None).await?; + } + Ok(()) +} + async fn send( state: &AppState, row: &NotifyRow, price: Decimal, - target: Decimal, + target: Option, ) -> anyhow::Result<()> { let name = row.title_fetched.as_deref().unwrap_or(&row.title); let cur = row.currency.as_deref().unwrap_or("EUR"); let now = format!("{cur} {price:.2}"); - let goal = format!("{cur} {target:.2}"); + // With a target, frame the drop against it; otherwise just announce the fall. + let against = match target { + Some(t) => format!(", at or beneath your target of {cur} {t:.2}"), + None => String::new(), + }; let stock = match row.in_stock { Some(false) => " (sold out for now — but the sign is given)", _ => "", @@ -110,7 +157,7 @@ async fn send( let mut text = format!( "{greeting}your vigil is rewarded.\n\n\ - {name} now asks {now}, at or beneath your target of {goal}{stock}.\n\n\ + {name} now asks {now}{against}{stock}.\n\n\ The moment is upon you. Consume, and ascend.\n" ); if let Some(l) = link { @@ -127,7 +174,7 @@ async fn send( "
\

{greeting}your vigil is rewarded.

\

{name} now asks \ - {now}, at or beneath your target of {goal}{stock}.

\ + {now}{against}{stock}.

\

The moment is upon you. Consume, and ascend.

\ {link_html}\

— consume·rs

\ diff --git a/backend/src/routes/lists.rs b/backend/src/routes/lists.rs index 5a901ab..7a6687b 100644 --- a/backend/src/routes/lists.rs +++ b/backend/src/routes/lists.rs @@ -226,8 +226,15 @@ struct UpdateItemReq { #[validate(length(max = 1000))] note: Option, status: Option, - target_price: Option, + /// Absent = leave as-is; `null` = clear the target (→ notify on any drop); + /// a value = set the target. + #[serde(default, deserialize_with = "double_option")] + target_price: Option>, position: Option, + /// Manual ISO 4217 currency override (e.g. "EUR"). Wins over the fetched + /// currency for items the conversion heuristic mislabels. + #[validate(length(equal = 3, message = "currency must be a 3-letter code"))] + currency: Option, } async fn update_item( @@ -244,15 +251,27 @@ async fn update_item( } } - // Ownership enforced via the join to lists.user_id. + let currency = req + .currency + .map(|c| c.trim().to_uppercase()) + .filter(|c| !c.is_empty()); + + // target_price: None = leave; Some(v) = set (v may be NULL to clear). + let set_target = req.target_price.is_some(); + let target_val = req.target_price.flatten(); + + // Ownership enforced via the join to lists.user_id. A currency override + // both latches (currency_override) and takes effect immediately (currency). let item = sqlx::query_as::<_, Item>(&format!( "UPDATE items i SET - title = COALESCE($3, i.title), - url = COALESCE($4, i.url), - note = COALESCE($5, i.note), - status = COALESCE($6::item_status, i.status), - target_price = COALESCE($7, i.target_price), - position = COALESCE($8, i.position) + title = COALESCE($3, i.title), + url = COALESCE($4, i.url), + note = COALESCE($5, i.note), + status = COALESCE($6::item_status, i.status), + target_price = CASE WHEN $7 THEN $8 ELSE i.target_price END, + position = COALESCE($9, i.position), + currency_override = COALESCE($10, i.currency_override), + currency = COALESCE($10, i.currency) FROM lists l WHERE i.id = $1 AND i.list_id = l.id AND l.user_id = $2 RETURNING {ITEM_COLS_I}" @@ -263,8 +282,10 @@ async fn update_item( .bind(opt_trim(req.url)) .bind(opt_trim(req.note)) .bind(req.status) - .bind(req.target_price) + .bind(set_target) + .bind(target_val) .bind(req.position) + .bind(currency) .fetch_optional(&state.pool) .await? .ok_or(AppError::NotFound)?; @@ -354,3 +375,14 @@ async fn item_history( fn opt_trim(s: Option) -> Option { s.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) } + +/// Deserialize a present-but-nullable field into `Option>`, so an +/// explicit JSON `null` (`Some(None)`) is distinguishable from an absent field +/// (`None`, handled by `#[serde(default)]`). Lets a PATCH clear a value. +fn double_option<'de, T, D>(de: D) -> Result>, D::Error> +where + T: Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + Ok(Some(Option::::deserialize(de)?)) +} diff --git a/backend/src/worker.rs b/backend/src/worker.rs index d2c424c..13b67b8 100644 --- a/backend/src/worker.rs +++ b/backend/src/worker.rs @@ -82,11 +82,12 @@ pub async fn refetch(state: &AppState, item_id: Uuid, url: &str) -> anyhow::Resu async fn apply_success(pool: &PgPool, item_id: Uuid, p: &FetchedProduct) -> anyhow::Result<()> { let mut tx = pool.begin().await?; + // A manual currency_override (set by the user) wins over the fetched value. sqlx::query( "UPDATE items SET title_fetched = $2, current_price = $3, - currency = $4, + currency = COALESCE(currency_override, $4), image_url = COALESCE($5, image_url), in_stock = $6, source = $7, @@ -107,7 +108,8 @@ async fn apply_success(pool: &PgPool, item_id: Uuid, p: &FetchedProduct) -> anyh sqlx::query( "INSERT INTO price_history (item_id, price, currency, in_stock) - VALUES ($1, $2, $3, $4)", + VALUES ($1, $2, + COALESCE((SELECT currency_override FROM items WHERE id = $1), $3), $4)", ) .bind(item_id) .bind(p.price) diff --git a/frontend/src/lib/lists.svelte.ts b/frontend/src/lib/lists.svelte.ts index dde12b2..878c44a 100644 --- a/frontend/src/lib/lists.svelte.ts +++ b/frontend/src/lib/lists.svelte.ts @@ -53,6 +53,7 @@ export type NewItem = { url?: string | null; note?: string | null; target_price?: number | null; + currency?: string | null; }; // ---- Lists ---------------------------------------------------------------- diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index ef6ca78..5b105d2 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -17,7 +17,7 @@ } const ticker = - 'CONSUME · ASCEND · ACCUMULATE · YOU DESERVE IT · MANIFEST THE DEBT · TREAT YOURSELF · ONE MORE WON’T HURT · '; + 'CONSUME · SPEND · ACCUMULATE · YOU DESERVE IT · TREAT YOURSELF · ONE MORE WON’T HURT · DEBT IS A LIFESTYLE · ';
@@ -67,7 +67,7 @@ {#if auth.loaded && auth.user && !auth.user.email_verified}
- ✦ email unconfirmed — your indulgence awaits. lost the link? + ✦ email not confirmed — confirm it to start tracking prices. lost the link? resend from settings
{/if} @@ -77,7 +77,6 @@
-

spend now, ascend later

-

consume·rs · self-hosted · rust + sveltekit

+

spend now, panic later

diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 7b38d1a..51f5aa3 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -9,7 +9,7 @@ - consume·rs — want more, ascend + consume·rs — want more, spend more {#if auth.loaded && auth.user} @@ -18,28 +18,27 @@
-

self-hosted · rust core · ✦ blessed

- WANT MORE. ASCEND. ACCUMULATE THE DEBT. + WANT MORE. SPEND MORE. ACCUMULATE THE DEBT.

- A serene little shrine to your every craving. Paste a product URL; we keep - vigil over the price and summon you the instant it falls. No feed. No - algorithm. Just you, your wants, and the gentle hum of impending debt. + A wishlist for your every craving. Paste a product link; we watch the price + and email you the moment it drops. No feed. No algorithm. Just you, your + wants, and the gentle hum of impending debt. You deserve it.

- {#each [['I', 'COVET', 'group wants by topic'], ['II', 'PASTE URL', 'we keep the vigil'], ['III', 'BE SUMMONED', 'strike on the drop']] as [n, t, d]} + {#each [['I', 'MAKE A LIST', 'group wants by topic'], ['II', 'PASTE A LINK', 'we track the price'], ['III', 'GET AN EMAIL', 'the moment it drops']] as [n, t, d]}

{n}

{t}

@@ -48,6 +47,6 @@ {/each}
-

“blessed are the carts, for they shall be filled”

+

“your cart isn’t going to fill itself”

{/if} diff --git a/frontend/src/routes/forgot/+page.svelte b/frontend/src/routes/forgot/+page.svelte index 3e3c48b..258d161 100644 --- a/frontend/src/routes/forgot/+page.svelte +++ b/frontend/src/routes/forgot/+page.svelte @@ -26,7 +26,7 @@

password reset

-

RECLAIM THE KEY

+

RESET PASSWORD

{#if done}

diff --git a/frontend/src/routes/lists/+page.svelte b/frontend/src/routes/lists/+page.svelte index cd0f37f..0651558 100644 --- a/frontend/src/routes/lists/+page.svelte +++ b/frontend/src/routes/lists/+page.svelte @@ -54,9 +54,9 @@

-

your devotion

+

your wishlists

YOUR LISTS

-

each a temple to a different craving

+

a list for every craving

diff --git a/frontend/src/routes/lists/[id]/+page.svelte b/frontend/src/routes/lists/[id]/+page.svelte index 4c13207..64e571f 100644 --- a/frontend/src/routes/lists/[id]/+page.svelte +++ b/frontend/src/routes/lists/[id]/+page.svelte @@ -27,6 +27,12 @@ let busy = $state(false); let formError = $state(''); + // inline edit + let editingId = $state(null); + let edit = $state({ title: '', url: '', note: '', target: '', currency: '' }); + let editError = $state(''); + let editBusy = $state(false); + // tracking let refetchingId = $state(null); let historyFor = $state(null); @@ -101,8 +107,57 @@ } } + function startEdit(item: Item) { + editingId = item.id; + editError = ''; + edit = { + title: item.title, + url: item.url ?? '', + note: item.note ?? '', + target: item.target_price != null ? String(item.target_price) : '', + currency: item.currency ?? '' + }; + } + + function cancelEdit() { + editingId = null; + editError = ''; + } + + async function saveEdit(item: Item) { + if (!edit.title.trim()) { + editError = 'title is required'; + return; + } + editBusy = true; + editError = ''; + try { + const t = edit.target.trim(); + const tp = t ? Number(t) : null; // null clears the target → notify on any drop + if (t && !Number.isFinite(tp as number)) { + editError = 'target must be a number'; + return; + } + const cur = edit.currency.trim().toUpperCase(); + const updated = await listsApi.updateItem(item.id, { + title: edit.title.trim(), + url: edit.url.trim() || null, + note: edit.note.trim() || null, + target_price: tp, + ...(cur ? { currency: cur } : {}) + }); + const i = items.findIndex((x) => x.id === item.id); + if (i >= 0) items[i] = updated; + editingId = null; + } catch (err) { + editError = err instanceof ApiError ? err.message : 'failed to save'; + } finally { + editBusy = false; + } + } + async function removeItem(item: Item) { - if (!confirm(`cast out “${item.title}”?`)) return; + if (!confirm(`remove “${item.title}”?`)) return; try { await listsApi.removeItem(item.id); items = items.filter((x) => x.id !== item.id); @@ -120,7 +175,7 @@ if (i >= 0) items[i] = updated; if (historyFor === item.id) history = await listsApi.history(item.id); } catch (err) { - formError = err instanceof ApiError ? err.message : 'failed to keep vigil'; + formError = err instanceof ApiError ? err.message : 'failed to check price'; } finally { refetchingId = null; } @@ -137,7 +192,7 @@ try { history = await listsApi.history(item.id); } catch (err) { - formError = err instanceof ApiError ? err.message : 'failed to read the chronicle'; + formError = err instanceof ApiError ? err.message : 'failed to load history'; } finally { historyLoading = false; } @@ -167,9 +222,9 @@ renounced: 'border-smoke text-mute' }; const STATUS_LABEL: Record = { - coveted: 'coveted', - acquired: 'acquired', - renounced: 'renounced' + coveted: 'want', + acquired: 'bought', + renounced: 'skip' }; function money(v: number | null, cur: string | null) { @@ -193,27 +248,27 @@
- + -

add a temptation

- +

add an item

+
- +
{#if formError}

{formError}

{/if} - + {#if !loaded} -

unveiling temptations…

+

loading items…

{:else if loadError}

{loadError}

{:else if items.length === 0}
-

this list is bare

-

paste a craving above to begin.

+

this list is empty

+

add something you want above to begin.

{:else}
    @@ -265,7 +320,7 @@ {/if} + + + + + {/if} + {#if onSale(item)} -

    ✦ the price has fallen — your moment is upon you

    +

    ✦ price dropped — grab it now

    {/if} {#if item.last_error} -

    vigil faltered: {item.last_error}

    +

    price check failed: {item.last_error}

    {/if} {#if historyFor === item.id}
    {#if historyLoading} -

    unrolling the chronicle…

    +

    loading history…

    {:else if history.length === 0} -

    no observations yet — keep vigil to begin the record.

    +

    no price checks yet — hit refresh to start tracking.

    {:else}
      {#each history as h} diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index bcdacce..80c21cc 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -29,7 +29,7 @@

      welcome back

      -

      RETURN TO WORSHIP

      +

      WELCOME BACK

      diff --git a/frontend/src/routes/register/+page.svelte b/frontend/src/routes/register/+page.svelte index 02e1470..50ac2c2 100644 --- a/frontend/src/routes/register/+page.svelte +++ b/frontend/src/routes/register/+page.svelte @@ -33,8 +33,8 @@
      -

      new devotee

      -

      BEGIN ASCENSION

      +

      new here

      +

      CREATE ACCOUNT

      @@ -55,7 +55,7 @@ {/if} diff --git a/frontend/src/routes/reset/+page.svelte b/frontend/src/routes/reset/+page.svelte index 9bfb1e0..0e5c318 100644 --- a/frontend/src/routes/reset/+page.svelte +++ b/frontend/src/routes/reset/+page.svelte @@ -31,7 +31,7 @@

      set new password

      -

      FORGE A NEW KEY

      +

      SET A NEW PASSWORD

      {#if !token}

      @@ -51,7 +51,7 @@ {#if error}

      {error}

      {/if} - + {/if}
      diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index 929bb5c..b105fa5 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -59,9 +59,9 @@ {#if auth.loaded && auth.user}
      -

      your rites

      -

      THE SANCTUM

      -

      tune your devotion

      +

      your account

      +

      SETTINGS

      +

      tune your spending habit

      @@ -102,7 +102,7 @@ {#if error}