From d7209318949323d223038a0ffd7f8242daee6af0 Mon Sep 17 00:00:00 2001 From: null Date: Thu, 14 May 2026 21:00:07 -0500 Subject: [PATCH] v0.27.02 push --- client/api.js | 5 +- client/components/ReleaseNotesDialog.jsx | 52 +++++----- client/hooks/useAuth.jsx | 42 ++++---- client/lib/version.js | 37 ++++++-- client/pages/AboutPage.jsx | 69 +++++++++++--- client/pages/SnowballPage.jsx | 70 ++++++++++++-- client/pages/StatusPage.jsx | 113 +++++++++++++++++++++- db/database.js | 31 ++++++ middleware/requireAuth.js | 2 +- package.json | 2 +- routes/aboutAdmin.js | 22 +++++ routes/auth.js | 24 ++++- routes/status.js | 8 +- routes/version.js | 11 +++ scripts/docker-push.sh | 7 +- services/authService.js | 3 +- services/updateCheckService.js | 116 +++++++++++++++++++++++ vite.config.js | 8 ++ 18 files changed, 540 insertions(+), 82 deletions(-) create mode 100644 services/updateCheckService.js diff --git a/client/api.js b/client/api.js index 358ffa9..0f9914c 100644 --- a/client/api.js +++ b/client/api.js @@ -48,7 +48,8 @@ export const api = { logout: () => post('/auth/logout'), restoreMultiUserMode: () => post('/auth/restore-multi-user-mode'), changePassword: (data) => post('/auth/change-password', data), - acknowledgePrivacy: () => post('/auth/acknowledge-privacy'), + acknowledgePrivacy: () => post('/auth/acknowledge-privacy'), + acknowledgeVersion: () => post('/auth/acknowledge-version'), // Admin hasUsers: () => get('/admin/has-users'), @@ -197,6 +198,8 @@ export const api = { about: () => get('/about'), aboutAdmin: () => get('/about-admin'), roadmap: () => get('/about-admin/roadmap'), + updateStatus: () => get('/version/update-status'), + checkForUpdates: () => post('/about-admin/check-updates'), devLog: () => get('/about-admin/dev-log'), version: () => get('/version'), releaseHistory: () => get('/version/history'), diff --git a/client/components/ReleaseNotesDialog.jsx b/client/components/ReleaseNotesDialog.jsx index 8afb8c6..bd198b6 100644 --- a/client/components/ReleaseNotesDialog.jsx +++ b/client/components/ReleaseNotesDialog.jsx @@ -1,44 +1,49 @@ -import { useState, useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { APP_VERSION, RELEASE_NOTES } from '@/lib/version'; import { Sparkles } from 'lucide-react'; +import { useAuth } from '@/hooks/useAuth'; +import { api } from '@/api'; -const STORAGE_KEY = `bt-release-seen-${APP_VERSION}`; +// Written on close so the dialog doesn't flash during the brief window before +// /me resolves on repeat visits. The backend is authoritative; this is a cache. +const LS_KEY = `bt-release-seen-${APP_VERSION}`; export function ReleaseNotesDialog() { + const { hasNewVersion, setHasNewVersion } = useAuth(); const [open, setOpen] = useState(false); const titleRef = useRef(null); useEffect(() => { - const seen = localStorage.getItem(STORAGE_KEY); - if (!seen) setOpen(true); - }, []); + if (hasNewVersion) setOpen(true); + }, [hasNewVersion]); const handleClose = () => { - localStorage.setItem(STORAGE_KEY, 'true'); + localStorage.setItem(LS_KEY, '1'); setOpen(false); - // Return focus to where it was before the dialog opened - const previouslyFocused = document.activeElement; - if (previouslyFocused && typeof previouslyFocused.focus === 'function') { - setTimeout(() => previouslyFocused.focus(), 0); - } + setHasNewVersion(false); // optimistic — don't wait for the server + api.acknowledgeVersion().catch(() => {}); // fire-and-forget + const prev = document.activeElement; + if (prev?.focus) setTimeout(() => prev.focus(), 0); }; return ( - { if (!o) handleClose(); }}> - + { if (!v) handleClose(); }}> +
- What's new in v{RELEASE_NOTES.version} + v{RELEASE_NOTES.version} · {RELEASE_NOTES.date}
- Bill Tracker is brand new - Release notes and new features overview + What's new + + Release highlights for BillTracker v{RELEASE_NOTES.version} +
@@ -47,24 +52,15 @@ export function ReleaseNotesDialog() {

{item.title}

-

{item.desc}

+

{item.desc}

))} -
- - Access original UI - +
diff --git a/client/hooks/useAuth.jsx b/client/hooks/useAuth.jsx index fab3565..b028802 100644 --- a/client/hooks/useAuth.jsx +++ b/client/hooks/useAuth.jsx @@ -4,45 +4,51 @@ import { api } from '@/api'; const AuthContext = createContext(null); export function AuthProvider({ children }) { - const [user, setUser] = useState(undefined); // undefined = loading - const [singleUserMode, setSUM] = useState(false); + const [user, setUser] = useState(undefined); // undefined = loading + const [singleUserMode, setSUM] = useState(false); + const [hasNewVersion, setHasNewVersion] = useState(false); + + function applyMeResponse(d) { + setUser(d.user); + setSUM(d.single_user_mode || false); + setHasNewVersion(!!d.has_new_version); + } useEffect(() => { - // Check if single-user mode first (bypasses login) api.authMode().then(d => { if (d.auth_mode === 'single') setSUM(true); }).catch(() => {}); - api.me() - .then(d => { setUser(d.user); setSUM(d.single_user_mode || false); }) - .catch(() => setUser(null)); - }, []); + api.me().then(applyMeResponse).catch(() => setUser(null)); + }, []); // eslint-disable-line const logout = async () => { await api.logout(); setUser(null); setSUM(false); + setHasNewVersion(false); }; const refresh = () => { - api.me() - .then(d => { - setUser(d.user); - setSUM(d.single_user_mode || false); - }) - .catch(() => { - setUser(null); - setSUM(false); - }); + api.me().then(applyMeResponse).catch(() => { + setUser(null); + setSUM(false); + }); }; return ( - + {children} ); } export function useAuth() { - return useContext(AuthContext) || { user: null, setUser: () => {}, logout: () => {}, refresh: () => {}, singleUserMode: false }; + return useContext(AuthContext) || { + user: null, setUser: () => {}, logout: () => {}, refresh: () => {}, + singleUserMode: false, hasNewVersion: false, setHasNewVersion: () => {}, + }; } diff --git a/client/lib/version.js b/client/lib/version.js index 4ee2762..44087cd 100644 --- a/client/lib/version.js +++ b/client/lib/version.js @@ -1,14 +1,37 @@ -export const APP_VERSION = '0.27.01'; +// __APP_VERSION__ is injected by Vite at build time from package.json. +// Do not hardcode a version string here — update package.json instead. +/* global __APP_VERSION__ */ +export const APP_VERSION = __APP_VERSION__; export const APP_NAME = 'BillTracker'; export const RELEASE_NOTES = { - version: '0.27.01', + version: APP_VERSION, date: '2026-05-14', highlights: [ - { icon: '❄️', title: 'Debt Snowball', desc: 'New Snowball page: drag-and-drop debt ordering, Dave Ramsey payoff projections, avalanche method comparison, and balance update by clicking any balance figure.' }, - { icon: '💳', title: 'Debt Details on Bills', desc: 'Add current balance, minimum payment, and APR directly to any bill. Bills in Credit Cards, Loans, and Mortgage categories are auto-detected.' }, - { icon: '📉', title: 'Payment → Balance Sync', desc: 'Recording a payment on a debt bill automatically reduces its current balance (principal = payment minus one month of interest). Un-marking a payment reverses the change.' }, - { icon: '📊', title: 'Dual-Column XLSX Import', desc: 'Bills due on the 1st and 15th are now both imported from dual-layout spreadsheets' }, - { icon: '🛡️', title: 'Import CSRF Fix', desc: 'XLSX, SQLite, and backup imports now include CSRF token (previously blocked with "session expired" error)' }, + { + icon: '❄️', + title: 'Debt Snowball', + desc: 'New Snowball page built around Dave Ramsey\'s method: drag-and-drop ordering, attack-target highlight, auto-arrange by balance, and per-bill payoff date that updates live as you type your extra monthly budget.', + }, + { + icon: '📉', + title: 'Payment → Balance sync', + desc: 'Recording a payment on any debt bill now automatically reduces its current balance (payment minus one month of accrued interest = principal paid). Un-marking a payment reverses the change exactly.', + }, + { + icon: '💳', + title: 'Debt Details on Bills', + desc: 'Edit Bill now has a collapsible Debt / Credit Details section: current balance (inline-editable on the Snowball page), minimum payment, and APR. Bills in Credit Cards, Loans, or Mortgage categories are auto-detected.', + }, + { + icon: '📊', + title: 'Avalanche comparison', + desc: 'The Snowball page sidebar shows your full payoff projection alongside an Avalanche method comparison — see how much interest you\'d save by attacking highest-rate debts first.', + }, + { + icon: '🔔', + title: 'Update notifications', + desc: 'The app now tracks which version you last saw. On your first login after an update you\'ll see this "What\'s new" panel. Admins can also check for newer releases from the Forgejo repo on the Status page.', + }, ], }; diff --git a/client/pages/AboutPage.jsx b/client/pages/AboutPage.jsx index 646e603..61988bf 100644 --- a/client/pages/AboutPage.jsx +++ b/client/pages/AboutPage.jsx @@ -1,13 +1,18 @@ import React, { useCallback, useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; -import { ArrowLeft, Info, Sparkles } from 'lucide-react'; +import { ArrowLeft, ArrowUpCircle, CheckCircle2, Info, Sparkles } from 'lucide-react'; import { api } from '@/api'; +import { useAuth } from '@/hooks/useAuth'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; + export default function AboutPage() { - const [about, setAbout] = useState(null); - const [loading, setLoading] = useState(true); + const { user } = useAuth(); + + const [about, setAbout] = useState(null); + const [loading, setLoading] = useState(true); + const [updateStatus, setUpdateStatus] = useState(null); const load = useCallback(async () => { setLoading(true); @@ -20,19 +25,21 @@ export default function AboutPage() { useEffect(() => { load(); }, [load]); - const stack = about?.stack || {}; + useEffect(() => { + api.updateStatus().then(setUpdateStatus).catch(() => {}); + }, []); return (
- +
@@ -44,10 +51,33 @@ export default function AboutPage() {
+ + {/* Version — with update status for admins */}

Version

v{about?.version || '...'}

+ {updateStatus && ( +
+ {updateStatus.has_update ? ( + + + v{updateStatus.latest_version} available + + ) : updateStatus.up_to_date ? ( + + + Up to date + + ) : null} +
+ )}
+

Backend

{about?.stack?.backend || 'Node.js / Express'}

@@ -74,13 +104,30 @@ export default function AboutPage() { - + {/* Only shown when the visitor is not signed in */} + {user == null && ( + + )}
+ + {/* Easter egg — barely visible, reveals on hover for curious explorers */} +
); -} \ No newline at end of file +} diff --git a/client/pages/SnowballPage.jsx b/client/pages/SnowballPage.jsx index c63e6f6..e50e7ef 100644 --- a/client/pages/SnowballPage.jsx +++ b/client/pages/SnowballPage.jsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { GripVertical, TrendingDown, Zap, Save, CalendarCheck, AlertTriangle, PenLine, EyeOff } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api'; @@ -31,6 +31,54 @@ function ordinal(n) { } } +// ── Client-side snowball simulation (mirrors server snowballService) ─────────── +// Runs in the browser so the payoff date updates instantly as the user types. +function computeLiveAttackPayoff(bills, extraPayment) { + const extra = Math.max(0, Number(extraPayment) || 0); + + const active = []; + for (const d of bills) { + const bal = Number(d.current_balance); + if (d.current_balance == null || !Number.isFinite(bal) || bal <= 0) continue; + active.push({ + balance: bal, + minPayment: Math.max(0, Number(d.minimum_payment) || 0), + monthlyRate: Math.max(0, Number(d.interest_rate) || 0) / 100 / 12, + payoffMonth: null, + }); + } + if (active.length === 0) return null; + + let rollingExtra = extra; + let month = 0; + + while (active.some(d => d.balance > 0) && month < 600) { + month++; + const targetIdx = active.findIndex(d => d.balance > 0); + for (let i = 0; i < active.length; i++) { + const d = active[i]; + if (d.balance <= 0) continue; + d.balance += d.balance * d.monthlyRate; + const payment = Math.min(d.balance, i === targetIdx ? d.minPayment + rollingExtra : d.minPayment); + d.balance = Math.max(0, d.balance - payment); + if (d.balance < 0.005) d.balance = 0; + } + for (const d of active) { + if (d.balance === 0 && d.payoffMonth === null) { + d.payoffMonth = month; + rollingExtra += d.minPayment; + } + } + } + + const first = active[0]; + if (!first?.payoffMonth) return null; + + const now = new Date(); + const date = new Date(now.getFullYear(), now.getMonth() + first.payoffMonth, 1); + return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long' }); +} + // ── StatCard ────────────────────────────────────────────────────────────────── function StatCard({ label, value, sub, highlight }) { return ( @@ -365,6 +413,12 @@ export default function SnowballPage() { } }; + // ── live payoff preview (updates as user types extra amount) ───────────── + const liveAttackPayoff = useMemo( + () => computeLiveAttackPayoff(bills, extraPayment), + [bills, extraPayment], + ); + // ── stats ───────────────────────────────────────────────────────────────── const totalBalance = bills.reduce((s, b) => s + (b.current_balance || 0), 0); const totalMinPayment = bills.reduce((s, b) => s + (b.minimum_payment || 0), 0); @@ -594,12 +648,16 @@ export default function SnowballPage() {
- {/* Attack card payoff line — from projection */} - {isAttack && attackProjection?.payoff_display && ( + {/* Attack payoff line — date is live (updates while typing), interest from server */} + {isAttack && (liveAttackPayoff || attackProjection?.payoff_display) && (
- ↳ Clears {attackProjection.payoff_display} - {attackProjection.total_interest > 0 && ( - · {fmtCompact(attackProjection.total_interest)} interest + + ↳ Clears {liveAttackPayoff ?? attackProjection.payoff_display} + + {attackProjection?.total_interest > 0 && ( + + · {fmtCompact(attackProjection.total_interest)} interest + )}
)} diff --git a/client/pages/StatusPage.jsx b/client/pages/StatusPage.jsx index 0070d21..ded3002 100644 --- a/client/pages/StatusPage.jsx +++ b/client/pages/StatusPage.jsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useCallback } from 'react'; import { Link } from 'react-router-dom'; -import { RefreshCw } from 'lucide-react'; +import { RefreshCw, ArrowUpCircle, CheckCircle2, AlertCircle } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api'; import { cn, fmtUptime, fmtBytes } from '@/lib/utils'; @@ -141,13 +141,96 @@ function ReleaseNotesSection({ version, historyMeta }) { ); } +// ─── Update Card ───────────────────────────────────────────────────────────── + +function UpdateCard({ update, onCheckNow, checking }) { + const hasUpdate = !!update.has_update; + const isKnown = update.up_to_date !== null && update.up_to_date !== undefined; + const hasError = !!update.error; + + const tone = hasUpdate ? 'warn' : isKnown && !hasError ? 'good' : 'muted'; + const status = hasUpdate ? 'Update Available' + : hasError ? 'Check Failed' + : isKnown ? 'Up to Date' + : 'Unknown'; + + const Icon = hasUpdate ? ArrowUpCircle + : hasError ? AlertCircle + : CheckCircle2; + + const iconCls = hasUpdate ? 'text-amber-500' + : hasError ? 'text-red-500' + : isKnown ? 'text-emerald-500' + : 'text-muted-foreground'; + + return ( + +
+ + + {hasUpdate + ? `v${update.latest_version} is available` + : isKnown && !hasError + ? 'Running the latest version' + : hasError + ? 'Could not reach update server' + : 'Status unknown'} + +
+ + + +
+ Latest Release + {update.latest_version ? ( + update.latest_release_url ? ( + + v{update.latest_version} ↗ + + ) : ( + v{update.latest_version} + ) + ) : ( + + )} +
+ + + + {update.error && ( +
+

{update.error}

+
+ )} + +
+ +
+
+ ); +} + // ─── StatusPage ─────────────────────────────────────────────────────────────── export default function StatusPage() { - const [data, setData] = useState(null); - const [version, setVersion] = useState(null); + const [data, setData] = useState(null); + const [version, setVersion] = useState(null); const [historyMeta, setHistoryMeta] = useState(null); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(true); + const [updateData, setUpdateData] = useState(null); + const [updateChecking, setUpdateChecking] = useState(false); const load = useCallback(async () => { setLoading(true); @@ -159,6 +242,7 @@ export default function StatusPage() { api.version(), ]); setData(statusData); + setUpdateData(statusData?.update ?? null); setVersion(versionData); try { const historyData = await api.releaseHistory(); @@ -178,6 +262,18 @@ export default function StatusPage() { useEffect(() => { load(); }, [load]); + const handleCheckNow = useCallback(async () => { + setUpdateChecking(true); + try { + const result = await api.checkForUpdates(); + setUpdateData(result); + } catch (err) { + toast.error(err.message || 'Update check failed'); + } finally { + setUpdateChecking(false); + } + }, []); + // Flatten nested status shape gracefully const app = data?.application ?? data?.app ?? {}; const rt = data?.runtime ?? {}; @@ -277,6 +373,15 @@ export default function StatusPage() {
+ + {updateData && ( + + )} + c.name); + return cols.includes('last_seen_version'); + }, + run: function() { + const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); + if (!cols.includes('last_seen_version')) { + db.exec('ALTER TABLE users ADD COLUMN last_seen_version TEXT'); + } + console.log('[migration] users: last_seen_version column added'); + } } ]; @@ -1264,6 +1279,18 @@ function runMigrations() { } console.log('[migration] bills: snowball_exempt column added'); } + }, + { + version: 'v0.52', + description: 'users: last_seen_version for release-notes notifications', + dependsOn: ['v0.51'], + run: function() { + const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); + if (!cols.includes('last_seen_version')) { + db.exec('ALTER TABLE users ADD COLUMN last_seen_version TEXT'); + } + console.log('[migration] users: last_seen_version column added'); + } } ]; @@ -1654,6 +1681,10 @@ const ROLLBACK_SQL_MAP = { 'v0.51': { description: 'bills: snowball_exempt column', sql: ['ALTER TABLE bills DROP COLUMN snowball_exempt'] + }, + 'v0.52': { + description: 'users: last_seen_version column', + sql: ['ALTER TABLE users DROP COLUMN last_seen_version'] } }; diff --git a/middleware/requireAuth.js b/middleware/requireAuth.js index eb79845..c4ff540 100644 --- a/middleware/requireAuth.js +++ b/middleware/requireAuth.js @@ -11,7 +11,7 @@ function getSingleModeUser() { // single-user mode bypasses session auth entirely. const row = getDb().prepare(` SELECT id, username, display_name, role, must_change_password, first_login, - active, is_default_admin + active, is_default_admin, last_seen_version FROM users WHERE id = ? AND role = 'user' AND active = 1 `).get(userId); diff --git a/package.json b/package.json index 18eebef..c3045dc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bill-tracker", - "version": "0.27.01", + "version": "0.27.02", "description": "Monthly bill tracking system", "main": "server.js", "scripts": { diff --git a/routes/aboutAdmin.js b/routes/aboutAdmin.js index bcb72e5..8dee651 100644 --- a/routes/aboutAdmin.js +++ b/routes/aboutAdmin.js @@ -455,4 +455,26 @@ router.get('/dev-log', requireAuth, requireAdmin, (req, res) => { } }); +const { checkForUpdates } = require('../services/updateCheckService'); + +// GET /api/about-admin/update-status — returns cached update check (no force-refresh) +router.get('/update-status', requireAuth, requireAdmin, async (req, res) => { + try { + const result = await checkForUpdates(false); + res.json(result); + } catch (err) { + res.status(500).json({ error: err.message || 'Update check failed' }); + } +}); + +// POST /api/about-admin/check-updates — force a fresh update check, bypassing cache +router.post('/check-updates', requireAuth, requireAdmin, async (req, res) => { + try { + const result = await checkForUpdates(true); + res.json(result); + } catch (err) { + res.status(500).json({ error: err.message || 'Update check failed' }); + } +}); + module.exports = router; diff --git a/routes/auth.js b/routes/auth.js index 9d7492f..dea3d3a 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -1,6 +1,14 @@ const express = require('express'); const router = express.Router(); +let _appVersion; +function getAppVersion() { + if (!_appVersion) { + try { _appVersion = require('../package.json').version; } catch { _appVersion = '0.0.0'; } + } + return _appVersion; +} + const { getDb, getSetting, setSetting } = require('../db/database'); const { login, logout, hashPassword, cookieOpts, COOKIE_NAME, rotateSessionId, invalidateOtherSessions } = require('../services/authService'); const { requireAuth, requireAdmin } = require('../middleware/requireAuth'); @@ -72,12 +80,24 @@ router.post('/logout-all', requireAuth, (req, res) => { // GET /api/auth/me router.get('/me', requireAuth, (req, res) => { + const currentVersion = getAppVersion(); res.json({ user: req.user, single_user_mode: !!req.singleUserMode, + current_version: currentVersion, + has_new_version: req.user.last_seen_version !== currentVersion, }); }); +// POST /api/auth/acknowledge-version — user has seen the release notes +router.post('/acknowledge-version', requireAuth, (req, res) => { + const currentVersion = getAppVersion(); + getDb() + .prepare("UPDATE users SET last_seen_version = ?, updated_at = datetime('now') WHERE id = ?") + .run(currentVersion, req.user.id); + res.json({ success: true, last_seen_version: currentVersion }); +}); + // GET /api/auth/mode // Public — tells the login page which options are available. // Never returns secrets. local_enabled/oidc_enabled reflect admin settings. @@ -199,8 +219,8 @@ router.post('/users', requireAuth, requireAdmin, async (req, res) => { const hash = await hashPassword(password); const result = db.prepare( - "INSERT INTO users (username, password_hash, role, first_login) VALUES (?, ?, 'user', 1)" - ).run(username, hash); + "INSERT INTO users (username, password_hash, role, first_login, last_seen_version) VALUES (?, ?, 'user', 1, ?)" + ).run(username, hash, getAppVersion()); const created = db.prepare( 'SELECT id, username, role, must_change_password, first_login, created_at FROM users WHERE id = ?' diff --git a/routes/status.js b/routes/status.js index 13d7724..3d4cfee 100644 --- a/routes/status.js +++ b/routes/status.js @@ -6,6 +6,7 @@ const { getDb, getSetting } = require('../db/database'); const { getStatusRuntime, recordError } = require('../services/statusRuntime'); const { listBackups } = require('../services/backupService'); const { getScheduleStatus } = require('../services/backupScheduler'); +const { checkForUpdates } = require('../services/updateCheckService'); const startTime = Date.now(); let pkg; @@ -29,7 +30,7 @@ function monthRange(now) { } // GET /api/status -router.get('/', (req, res) => { +router.get('/', async (req, res) => { const runtimeState = getStatusRuntime(); const now = new Date(); const uptimeSeconds = Math.floor((Date.now() - startTime) / 1000); @@ -258,6 +259,10 @@ router.get('/', (req, res) => { last_error: runtimeState.worker.last_error, }; + // Update check — non-blocking; uses cached result if available + let update = { current_version: pkg.version, latest_version: null, up_to_date: null, has_update: false, error: null, last_checked_at: null }; + try { update = await checkForUpdates(); } catch { /* non-fatal */ } + const recentErrors = getStatusRuntime().recentErrors; const ok = database.ok && tracker.ok; @@ -281,6 +286,7 @@ router.get('/', (req, res) => { server, tracker, cleanup, + update, recent_errors: recentErrors, errors: recentErrors, version: pkg.version, diff --git a/routes/version.js b/routes/version.js index 0c1b598..7c1d137 100644 --- a/routes/version.js +++ b/routes/version.js @@ -76,4 +76,15 @@ router.get('/history', (req, res) => { } }); +// GET /api/version/update-status — public, returns cached update check (no force-refresh) +const { checkForUpdates } = require('../services/updateCheckService'); + +router.get('/update-status', async (req, res) => { + try { + res.json(await checkForUpdates(false)); + } catch (err) { + res.json({ current_version: pkg.version, latest_version: null, up_to_date: null, has_update: false, error: err.message }); + } +}); + module.exports = router; diff --git a/scripts/docker-push.sh b/scripts/docker-push.sh index cf83c64..6c36ef4 100755 --- a/scripts/docker-push.sh +++ b/scripts/docker-push.sh @@ -10,8 +10,13 @@ source ~/.openclaw/docker-registry.env echo "$FORGEJO_REGISTRY_TOKEN" | docker login "$FORGEJO_REGISTRY" -u "$FORGEJO_REGISTRY_USER" --password-stdin +VERSION=$(node -e "console.log(require('./package.json').version)") +VERSION_TAG="dev-v${VERSION}" + docker tag bill-tracker:local "${FORGEJO_REGISTRY}/null/bill-tracker:dev" +docker tag bill-tracker:local "${FORGEJO_REGISTRY}/null/bill-tracker:${VERSION_TAG}" docker push "${FORGEJO_REGISTRY}/null/bill-tracker:dev" +docker push "${FORGEJO_REGISTRY}/null/bill-tracker:${VERSION_TAG}" docker logout "$FORGEJO_REGISTRY" -echo "✓ Pushed dev image" \ No newline at end of file +echo "✓ Pushed dev + ${VERSION_TAG} images" \ No newline at end of file diff --git a/services/authService.js b/services/authService.js index 945ec78..2bc12b0 100644 --- a/services/authService.js +++ b/services/authService.js @@ -145,7 +145,7 @@ function getSessionUser(sessionId) { if (!sessionId) return null; const row = getDb().prepare(` SELECT u.id, u.username, u.display_name, u.role, u.must_change_password, u.first_login, - u.active, u.is_default_admin + u.active, u.is_default_admin, u.last_seen_version FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.id = ? AND s.expires_at > datetime('now') AND u.active = 1 @@ -167,6 +167,7 @@ function publicUser(u) { is_default_admin: !!u.is_default_admin, must_change_password: !!u.must_change_password, first_login: !!u.first_login, + last_seen_version: u.last_seen_version || null, }; } diff --git a/services/updateCheckService.js b/services/updateCheckService.js new file mode 100644 index 0000000..1932463 --- /dev/null +++ b/services/updateCheckService.js @@ -0,0 +1,116 @@ +/** + * Checks the Forgejo repo for newer releases and compares against the running + * package.json version. Results are cached in memory (1 hour for success, + * 5 minutes for errors) so the status page stays fast under load. + */ + +const REPO_API_BASE = process.env.REPO_API_URL + || 'https://dream.scheller.ltd/api/v1/repos/null/BillTracker'; + +const TTL_OK_MS = 60 * 60 * 1000; // 1 hour on success +const TTL_ERROR_MS = 5 * 60 * 1000; // 5 min on error (avoid hammering) +const FETCH_TIMEOUT_MS = 8_000; + +let _cache = { result: null, expiresAt: 0 }; +let _pkg = null; + +function getCurrentVersion() { + if (!_pkg) { + try { _pkg = require('../package.json'); } catch { _pkg = { version: '0.0.0' }; } + } + return _pkg.version; +} + +// Returns positive if a > b, negative if a < b, 0 if equal. +function compareVersions(a, b) { + const parse = v => String(v).replace(/^v/, '').split('.').map(Number); + const pa = parse(a), pb = parse(b); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const diff = (pa[i] || 0) - (pb[i] || 0); + if (diff !== 0) return diff; + } + return 0; +} + +/** + * @param {boolean} force Skip the cache and always hit the API. + * @returns {Promise} Update status object. + */ +async function checkForUpdates(force = false) { + const now = Date.now(); + + if (!force && _cache.result && now < _cache.expiresAt) { + return { ..._cache.result, cached: true }; + } + + const currentVersion = getCurrentVersion(); + const checkedAt = new Date().toISOString(); + + try { + const res = await fetch(`${REPO_API_BASE}/releases/latest`, { + headers: { + 'Accept': 'application/json', + 'User-Agent': `BillTracker/${currentVersion} UpdateCheck`, + }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + + // 404 = no releases published yet — treat as up-to-date + if (res.status === 404) { + const result = { + current_version: currentVersion, + latest_version: null, + up_to_date: true, + has_update: false, + latest_release_url: null, + published_at: null, + last_checked_at: checkedAt, + error: null, + cached: false, + }; + _cache = { result, expiresAt: now + TTL_OK_MS }; + return result; + } + + if (!res.ok) throw new Error(`Forgejo API returned HTTP ${res.status}`); + + const release = await res.json(); + const rawTag = release.tag_name || ''; + const latestVersion = rawTag.replace(/^v/, '') || null; + const hasUpdate = latestVersion + ? compareVersions(currentVersion, latestVersion) < 0 + : false; + + const result = { + current_version: currentVersion, + latest_version: latestVersion, + up_to_date: !hasUpdate, + has_update: hasUpdate, + latest_release_url: release.html_url || null, + published_at: release.published_at || null, + last_checked_at: checkedAt, + error: null, + cached: false, + }; + + _cache = { result, expiresAt: now + TTL_OK_MS }; + return result; + + } catch (err) { + const result = { + current_version: currentVersion, + latest_version: null, + up_to_date: null, // unknown — network or API failure + has_update: false, + latest_release_url: null, + published_at: null, + last_checked_at: checkedAt, + error: err.message || 'Update check failed', + cached: false, + }; + _cache = { result, expiresAt: now + TTL_ERROR_MS }; + return result; + } +} + +module.exports = { checkForUpdates }; diff --git a/vite.config.js b/vite.config.js index e776262..70ecd0e 100644 --- a/vite.config.js +++ b/vite.config.js @@ -2,12 +2,20 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import path from 'path'; import { fileURLToPath } from 'url'; +import { createRequire } from 'module'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); +const pkg = require('./package.json'); export default defineConfig({ plugins: [react()], publicDir: 'client/public', + define: { + // Injected at build time — frontend reads this instead of maintaining a + // duplicate version string in client/lib/version.js + __APP_VERSION__: JSON.stringify(pkg.version), + }, resolve: { alias: { '@': path.resolve(__dirname, './client') }, },