diff options
| author | srdusr <[email protected]> | 2025-09-13 21:11:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2025-09-13 21:11:00 +0200 |
| commit | 0d3864bcebcdba1b1743367d0f84389fd53e37e1 (patch) | |
| tree | 54a5c028b9fe9b965ffc0240f734b2b5ac81046b /crates/server/src | |
| parent | 29992fe40703cf567177d70f152811bfedb0caad (diff) | |
| download | typerpunk-0d3864bcebcdba1b1743367d0f84389fd53e37e1.tar.gz typerpunk-0d3864bcebcdba1b1743367d0f84389fd53e37e1.zip | |
Rework the end screen figures, mode picker, corners and race view
End screen
- Every figure is now a dim label directly over its value, the two centred on
each other, grouped by what they qualify: RAW and PB under WPM, ERR and
CONSISTENCY under ACC, KEYSTROKES and CHARACTERS either side of TIME. The
row is a grid so TIME sits on the graph's exact centre line rather than
drifting with its neighbours' widths.
- Errors go back onto the wpm line. Their own y axis implied a magnitude a
mistake does not have; what matters is when one happened.
- Graph hover reads "wpm ... raw ... time" - the figures first, the second
they happened in as the qualifier.
Mode picker
- Opens from the Single Player button itself, and choosing a mode starts it:
picking what to type and starting it are one action. The control has now
been a chevron notched into that button, a caption between the two buttons,
a pill above them and a chip row below them; as the button's own menu it
needs no separate real estate at all.
Corners
- Settings and Store move to the bottom-left. The top-left is the wordmark's
alone.
- The global racer count is gone from the home screen - it is not something
you can act on there. The Friends control carries "N online" instead, which
is.
- Presence: users gain a last_seen column, touched at most once a minute per
active user on any authenticated request, and the friends list reports who
has been seen inside a five-minute window.
Multiplayer race view
- The standings move to the middle of the screen, the space the end screen's
graph occupies, and now include your own row rather than opponents only.
Pinned to the top-left corner they put what you are racing against in your
peripheral vision and left out the one bar you most need to see.
Programming mode
- Snippets carry an explanation of what the code does. Shown under the passage
while you type in single player; in multiplayer that space belongs to the
standings, so it waits for the end screen, where it appears either way.
Diffstat (limited to 'crates/server/src')
| -rw-r--r-- | crates/server/src/auth.rs | 30 | ||||
| -rw-r--r-- | crates/server/src/friends.rs | 14 |
2 files changed, 42 insertions, 2 deletions
diff --git a/crates/server/src/auth.rs b/crates/server/src/auth.rs index d5294c4..c1402d5 100644 --- a/crates/server/src/auth.rs +++ b/crates/server/src/auth.rs @@ -250,12 +250,38 @@ pub async fn user_from_token(db: &sqlx::SqlitePool, token: &str) -> Option<UserV /// Cookie session first (the browser's path), then an `Authorization: /// Bearer <token>` header (the CLI's path) - lets a handler serve both /// kinds of client without needing to know which one it's talking to. +/// How stale a user's last_seen may be before they stop counting as online. +pub const PRESENCE_WINDOW_SECS: i64 = 300; +/// How often a single user's last_seen is actually written. Every +/// authenticated request touching the row would be a write per request, which +/// SQLite's single writer would not thank us for; once a minute is plenty for +/// a five-minute window. +const PRESENCE_WRITE_INTERVAL_SECS: i64 = 60; + +/// Records that this user is around. Cheap enough to call on every +/// authenticated request: the WHERE clause skips the write unless the stored +/// value is already stale, so it is one no-op UPDATE a minute per active user +/// rather than one per request. +async fn touch_last_seen(db: &sqlx::SqlitePool, user_id: &str) { + let now = OffsetDateTime::now_utc(); + let cutoff = now - TimeDuration::seconds(PRESENCE_WRITE_INTERVAL_SECS); + let _ = sqlx::query( + "UPDATE users SET last_seen = ? WHERE id = ? AND (last_seen IS NULL OR last_seen < ?)", + ) + .bind(format_timestamp(now)) + .bind(user_id) + .bind(format_timestamp(cutoff)) + .execute(db) + .await; +} + pub async fn current_user_or_token( db: &sqlx::SqlitePool, jar: &CookieJar, headers: &axum::http::HeaderMap, ) -> Option<UserView> { if let Some(user) = current_user(db, jar).await { + touch_last_seen(db, &user.id).await; return Some(user); } let token = headers @@ -263,7 +289,9 @@ pub async fn current_user_or_token( .to_str() .ok()? .strip_prefix("Bearer ")?; - user_from_token(db, token).await + let user = user_from_token(db, token).await?; + touch_last_seen(db, &user.id).await; + Some(user) } async fn logout(State(state): State<Arc<AppState>>, jar: CookieJar) -> Result<impl IntoResponse, AppError> { diff --git a/crates/server/src/friends.rs b/crates/server/src/friends.rs index e720dbf..6621def 100644 --- a/crates/server/src/friends.rs +++ b/crates/server/src/friends.rs @@ -25,6 +25,9 @@ struct FriendEntry { friendship_id: String, user_id: String, username: String, + /// Only meaningful for accepted friends; a pending request has no + /// presence worth reporting, so it is always false there. + online: bool, } #[derive(Debug, Serialize)] @@ -39,6 +42,8 @@ fn row_to_entry(row: &sqlx::sqlite::SqliteRow, friendship_id_col: &str, user_id_ friendship_id: row.try_get(friendship_id_col).unwrap_or_default(), user_id: row.try_get(user_id_col).unwrap_or_default(), username: row.try_get(username_col).unwrap_or_default(), + // Absent on the pending-request queries, which do not select it. + online: row.try_get::<i64, _>("online").unwrap_or(0) != 0, } } @@ -48,12 +53,19 @@ async fn list_friends(State(state): State<Arc<AppState>>, jar: CookieJar, header // Accepted, in either direction - the "other" user is whichever side // isn't us, so this always returns the friend's identity regardless of // who originally sent the request. + // last_seen within the presence window marks a friend as online. Compared + // in SQL rather than in Rust so the list arrives ready to render. + let presence_cutoff = crate::auth::format_timestamp( + time::OffsetDateTime::now_utc() - time::Duration::seconds(crate::auth::PRESENCE_WINDOW_SECS), + ); let accepted = sqlx::query( - "SELECT friendships.id as friendship_id, users.id as user_id, users.username as username + "SELECT friendships.id as friendship_id, users.id as user_id, users.username as username, + (users.last_seen IS NOT NULL AND users.last_seen > ?) as online FROM friendships JOIN users ON users.id = CASE WHEN friendships.requester_id = ? THEN friendships.addressee_id ELSE friendships.requester_id END WHERE friendships.status = 'accepted' AND (friendships.requester_id = ? OR friendships.addressee_id = ?)", ) + .bind(&presence_cutoff) .bind(&user.id).bind(&user.id).bind(&user.id) .fetch_all(&state.db) .await?; |