feat(marketing): add product of the day badge (#17)

This commit is contained in:
hampus-fluxer 2026-01-04 13:39:37 +01:00 committed by GitHub
parent bb4ab2bcaa
commit e191cfb15e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 212 additions and 53 deletions

View File

@ -47,11 +47,21 @@ pub fn main() {
rpc_secret: cfg.gateway_rpc_secret, rpc_secret: cfg.gateway_rpc_secret,
)) ))
let badge_cache = badge_proxy.start_cache() let badge_featured_cache =
badge_proxy.start_cache(badge_proxy.product_hunt_featured_url)
let badge_top_post_cache =
badge_proxy.start_cache(badge_proxy.product_hunt_top_post_url)
let assert Ok(_) = let assert Ok(_) =
wisp_mist.handler( wisp_mist.handler(
handle_request(_, i18n_db, cfg, slots_cache, badge_cache), handle_request(
_,
i18n_db,
cfg,
slots_cache,
badge_featured_cache,
badge_top_post_cache,
),
cfg.secret_key_base, cfg.secret_key_base,
) )
|> mist.new |> mist.new
@ -67,7 +77,8 @@ fn handle_request(
i18n_db, i18n_db,
cfg: config.Config, cfg: config.Config,
slots_cache: visionary_slots.Cache, slots_cache: visionary_slots.Cache,
badge_cache: badge_proxy.Cache, badge_featured_cache: badge_proxy.Cache,
badge_top_post_cache: badge_proxy.Cache,
) -> wisp.Response { ) -> wisp.Response {
let locale = get_request_locale(req) let locale = get_request_locale(req)
@ -100,7 +111,8 @@ fn handle_request(
release_channel: cfg.release_channel, release_channel: cfg.release_channel,
visionary_slots: visionary_slots.current(slots_cache), visionary_slots: visionary_slots.current(slots_cache),
metrics_endpoint: cfg.metrics_endpoint, metrics_endpoint: cfg.metrics_endpoint,
badge_cache: badge_cache, badge_featured_cache: badge_featured_cache,
badge_top_post_cache: badge_top_post_cache,
) )
use <- wisp.log_request(req) use <- wisp.log_request(req)

View File

@ -21,7 +21,9 @@ import gleam/httpc
import gleam/option.{type Option} import gleam/option.{type Option}
import wisp.{type Response} import wisp.{type Response}
const product_hunt_url = "https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1057558&theme=light" pub const product_hunt_featured_url = "https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1057558&theme=light"
pub const product_hunt_top_post_url = "https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=1057558&theme=light&period=daily&t=1767529639613"
const stale_after_ms = 300_000 const stale_after_ms = 300_000
@ -46,45 +48,46 @@ type State {
State(cache: Option(CacheEntry), is_refreshing: Bool) State(cache: Option(CacheEntry), is_refreshing: Bool)
} }
pub fn start_cache() -> Cache { pub fn start_cache(url: String) -> Cache {
let started = process.new_subject() let started = process.new_subject()
let _ = process.spawn_unlinked(fn() { run(started) }) let _ = process.spawn_unlinked(fn() { run(started, url) })
let assert Ok(subject) = process.receive(started, within: 1000) let assert Ok(subject) = process.receive(started, within: 1000)
Cache(subject: subject) Cache(subject: subject)
} }
fn run(started: process.Subject(process.Subject(ServerMessage))) { fn run(started: process.Subject(process.Subject(ServerMessage)), url: String) {
let subject = process.new_subject() let subject = process.new_subject()
process.send(started, subject) process.send(started, subject)
let initial = State(cache: option.None, is_refreshing: False) let initial = State(cache: option.None, is_refreshing: False)
loop(subject, initial) loop(subject, url, initial)
} }
fn loop(subject: process.Subject(ServerMessage), state: State) { fn loop(subject: process.Subject(ServerMessage), url: String, state: State) {
let new_state = case process.receive(subject, within: stale_after_ms) { let new_state = case process.receive(subject, within: stale_after_ms) {
Ok(Get(reply_to)) -> handle_get(subject, reply_to, state) Ok(Get(reply_to)) -> handle_get(subject, reply_to, url, state)
Ok(RefreshDone(fetched_at, svg)) -> Ok(RefreshDone(fetched_at, svg)) ->
handle_refresh_done(fetched_at, svg, state) handle_refresh_done(fetched_at, svg, state)
Error(_) -> maybe_refresh_in_background(subject, state) Error(_) -> maybe_refresh_in_background(subject, url, state)
} }
loop(subject, new_state) loop(subject, url, new_state)
} }
fn handle_get( fn handle_get(
subject: process.Subject(ServerMessage), subject: process.Subject(ServerMessage),
reply_to: process.Subject(Option(String)), reply_to: process.Subject(Option(String)),
url: String,
state: State, state: State,
) -> State { ) -> State {
let now = monotonic_time_ms() let now = monotonic_time_ms()
case state.cache { case state.cache {
option.None -> { option.None -> {
let svg = fetch_badge_svg() let svg = fetch_badge_svg(url)
process.send(reply_to, svg) process.send(reply_to, svg)
let new_cache = case svg { let new_cache = case svg {
@ -103,7 +106,7 @@ fn handle_get(
case is_stale && !state.is_refreshing { case is_stale && !state.is_refreshing {
True -> { True -> {
spawn_refresh(subject) spawn_refresh(subject, url)
State(..state, is_refreshing: True) State(..state, is_refreshing: True)
} }
False -> state False -> state
@ -128,13 +131,14 @@ fn handle_refresh_done(
fn maybe_refresh_in_background( fn maybe_refresh_in_background(
subject: process.Subject(ServerMessage), subject: process.Subject(ServerMessage),
url: String,
state: State, state: State,
) -> State { ) -> State {
let now = monotonic_time_ms() let now = monotonic_time_ms()
case state.cache, state.is_refreshing { case state.cache, state.is_refreshing {
option.Some(entry), False if now - entry.fetched_at > stale_after_ms -> { option.Some(entry), False if now - entry.fetched_at > stale_after_ms -> {
spawn_refresh(subject) spawn_refresh(subject, url)
State(..state, is_refreshing: True) State(..state, is_refreshing: True)
} }
@ -142,11 +146,11 @@ fn maybe_refresh_in_background(
} }
} }
fn spawn_refresh(subject: process.Subject(ServerMessage)) { fn spawn_refresh(subject: process.Subject(ServerMessage), url: String) {
let _ = let _ =
process.spawn_unlinked(fn() { process.spawn_unlinked(fn() {
let fetched_at = monotonic_time_ms() let fetched_at = monotonic_time_ms()
let svg = fetch_badge_svg() let svg = fetch_badge_svg(url)
process.send(subject, RefreshDone(fetched_at, svg)) process.send(subject, RefreshDone(fetched_at, svg))
}) })
@ -185,8 +189,8 @@ pub fn product_hunt(cache: Cache) -> Response {
} }
} }
fn fetch_badge_svg() -> Option(String) { fn fetch_badge_svg(url: String) -> Option(String) {
let assert Ok(req0) = request.to(product_hunt_url) let assert Ok(req0) = request.to(url)
let req = let req =
req0 req0
|> request.prepend_header("accept", "image/svg+xml") |> request.prepend_header("accept", "image/svg+xml")

View File

@ -84,27 +84,59 @@ pub fn render(ctx: Context) -> Element(a) {
], ],
), ),
hackernews_banner.render(ctx), hackernews_banner.render(ctx),
html.div([attribute.class("mt-6 flex justify-center")], [ html.div(
html.a( [
[ attribute.class(
attribute.href( "mt-6 flex flex-wrap items-center justify-center gap-4",
"https://www.producthunt.com/products/fluxer?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-fluxer", ),
), ],
attribute.target("_blank"), [
attribute.attribute("rel", "noopener noreferrer"), html.a(
], [
[ attribute.href(
html.img([ "https://www.producthunt.com/products/fluxer?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-fluxer",
attribute.alt(
"Fluxer - Open-source Discord-like instant messaging & VoIP platform | Product Hunt",
), ),
attribute.attribute("width", "250"), attribute.target("_blank"),
attribute.attribute("height", "54"), attribute.attribute("rel", "noopener noreferrer"),
attribute.src(prepend_base_path(ctx, "/api/badges/product-hunt")), ],
]), [
], html.img([
), attribute.alt(
]), "Fluxer - Open-source Discord-like instant messaging & VoIP platform | Product Hunt",
),
attribute.attribute("width", "250"),
attribute.attribute("height", "54"),
attribute.src(prepend_base_path(
ctx,
"/api/badges/product-hunt",
)),
]),
],
),
html.a(
[
attribute.href(
"https://www.producthunt.com/products/fluxer?embed=true&utm_source=badge-top-post-badge&utm_medium=badge&utm_campaign=badge-fluxer",
),
attribute.target("_blank"),
attribute.attribute("rel", "noopener noreferrer"),
],
[
html.img([
attribute.alt(
"Fluxer - Open-source Discord-like instant messaging & VoIP platform | Product Hunt Top Post",
),
attribute.attribute("width", "250"),
attribute.attribute("height", "54"),
attribute.src(prepend_base_path(
ctx,
"/api/badges/product-hunt-top-post",
)),
]),
],
),
],
),
]), ]),
html.div( html.div(
[ [

View File

@ -51,7 +51,10 @@ pub fn handle_request(req: Request, ctx: Context) -> Response {
["_debug", "geoip"] -> handle_geoip_debug(req, ctx) ["_debug", "geoip"] -> handle_geoip_debug(req, ctx)
["api", "badges", "product-hunt"] -> ["api", "badges", "product-hunt"] ->
badge_proxy.product_hunt(ctx.badge_cache) badge_proxy.product_hunt(ctx.badge_featured_cache)
["api", "badges", "product-hunt-top-post"] ->
badge_proxy.product_hunt(ctx.badge_top_post_cache)
["robots.txt"] -> handle_robots_txt() ["robots.txt"] -> handle_robots_txt()
["sitemap.xml"] -> handle_sitemap(ctx) ["sitemap.xml"] -> handle_sitemap(ctx)

View File

@ -22,6 +22,7 @@ import gleam/http/request
import gleam/httpc import gleam/httpc
import gleam/int import gleam/int
import gleam/json import gleam/json
import gleam/option
import gleam/result import gleam/result
import gleam/string import gleam/string
import wisp import wisp
@ -40,9 +41,10 @@ pub opaque type Cache {
type ServerMessage { type ServerMessage {
Get(process.Subject(VisionarySlots)) Get(process.Subject(VisionarySlots))
RefreshDone(fetched_at: Int, slots: option.Option(VisionarySlots))
} }
const refresh_interval_ms = 300_000 const stale_after_ms = 300_000
const receive_timeout_ms = 200 const receive_timeout_ms = 200
@ -60,30 +62,124 @@ pub fn start(settings: Settings) -> Cache {
Cache(name: name) Cache(name: name)
} }
type CacheEntry {
CacheEntry(slots: VisionarySlots, fetched_at: Int)
}
type State {
State(cache: option.Option(CacheEntry), is_refreshing: Bool)
}
fn run(name: process.Name(ServerMessage), settings: Settings) { fn run(name: process.Name(ServerMessage), settings: Settings) {
let _ = process.register(process.self(), name) let _ = process.register(process.self(), name)
let subject = process.named_subject(name) let subject = process.named_subject(name)
let initial = fetch_slots(settings) |> result.unwrap(default_slots()) let initial_slots = fetch_slots(settings) |> result.unwrap(default_slots())
loop(subject, settings, initial) let now = monotonic_time_ms()
let initial_state =
State(
cache: option.Some(CacheEntry(slots: initial_slots, fetched_at: now)),
is_refreshing: False,
)
loop(subject, settings, initial_state)
} }
fn loop( fn loop(
subject: process.Subject(ServerMessage), subject: process.Subject(ServerMessage),
settings: Settings, settings: Settings,
state: VisionarySlots, state: State,
) { ) {
case process.receive(subject, within: refresh_interval_ms) { let new_state = case process.receive(subject, within: stale_after_ms) {
Ok(Get(reply_to)) -> { Ok(Get(reply_to)) -> handle_get(subject, reply_to, settings, state)
process.send(reply_to, state)
loop(subject, settings, state) Ok(RefreshDone(fetched_at, slots)) ->
handle_refresh_done(fetched_at, slots, state)
Error(_) -> maybe_refresh_in_background(subject, settings, state)
}
loop(subject, settings, new_state)
}
fn handle_get(
subject: process.Subject(ServerMessage),
reply_to: process.Subject(VisionarySlots),
settings: Settings,
state: State,
) -> State {
let now = monotonic_time_ms()
case state.cache {
option.None -> {
let slots = fetch_slots(settings) |> result.unwrap(default_slots())
process.send(reply_to, slots)
let entry = CacheEntry(slots: slots, fetched_at: now)
State(cache: option.Some(entry), is_refreshing: False)
} }
Error(_) -> {
let updated = fetch_slots(settings) |> result.unwrap(state) option.Some(entry) -> {
loop(subject, settings, updated) let is_stale = now - entry.fetched_at > stale_after_ms
process.send(reply_to, entry.slots)
case is_stale && !state.is_refreshing {
True -> {
spawn_refresh(subject, settings)
State(..state, is_refreshing: True)
}
False -> state
}
} }
} }
} }
fn handle_refresh_done(
fetched_at: Int,
slots: option.Option(VisionarySlots),
state: State,
) -> State {
let new_cache = case slots {
option.Some(data) ->
option.Some(CacheEntry(slots: data, fetched_at: fetched_at))
option.None -> state.cache
}
State(cache: new_cache, is_refreshing: False)
}
fn maybe_refresh_in_background(
subject: process.Subject(ServerMessage),
settings: Settings,
state: State,
) -> State {
let now = monotonic_time_ms()
case state.cache, state.is_refreshing {
option.Some(entry), False if now - entry.fetched_at > stale_after_ms -> {
spawn_refresh(subject, settings)
State(..state, is_refreshing: True)
}
_, _ -> state
}
}
fn spawn_refresh(subject: process.Subject(ServerMessage), settings: Settings) {
let _ =
process.spawn_unlinked(fn() {
let fetched_at = monotonic_time_ms()
let result = fetch_slots(settings)
let payload = case result {
Ok(slots) -> option.Some(slots)
Error(_) -> option.None
}
process.send(subject, RefreshDone(fetched_at, payload))
})
Nil
}
pub fn current(cache: Cache) -> VisionarySlots { pub fn current(cache: Cache) -> VisionarySlots {
let subject = process.named_subject(cache.name) let subject = process.named_subject(cache.name)
let reply_to = process.new_subject() let reply_to = process.new_subject()
@ -368,3 +464,14 @@ fn rpc_url(api_host: String) -> String {
fn default_slots() -> VisionarySlots { fn default_slots() -> VisionarySlots {
VisionarySlots(total: 0, bought: 0, remaining: 0) VisionarySlots(total: 0, bought: 0, remaining: 0)
} }
type TimeUnit {
Millisecond
}
@external(erlang, "erlang", "monotonic_time")
fn erlang_monotonic_time(unit: TimeUnit) -> Int
fn monotonic_time_ms() -> Int {
erlang_monotonic_time(Millisecond)
}

View File

@ -56,7 +56,8 @@ pub type Context {
release_channel: String, release_channel: String,
visionary_slots: VisionarySlots, visionary_slots: VisionarySlots,
metrics_endpoint: Option(String), metrics_endpoint: Option(String),
badge_cache: badge_proxy.Cache, badge_featured_cache: badge_proxy.Cache,
badge_top_post_cache: badge_proxy.Cache,
) )
} }