v0.27.02 push

This commit is contained in:
null 2026-05-14 21:00:07 -05:00
parent eea5641126
commit d720931894
18 changed files with 540 additions and 82 deletions

View File

@ -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'),

View File

@ -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 (
<Dialog open={open} onOpenChange={(o) => { if (!o) handleClose(); }}>
<DialogContent className="max-w-md" aria-labelledby={titleRef.current?.id}>
<Dialog open={open} onOpenChange={v => { if (!v) handleClose(); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<div className="flex items-center gap-2 mb-1">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10">
<Sparkles className="h-4 w-4 text-primary" />
</div>
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
What's new in v{RELEASE_NOTES.version}
v{RELEASE_NOTES.version} · {RELEASE_NOTES.date}
</span>
</div>
<DialogTitle ref={titleRef} className="text-xl">Bill Tracker is brand new</DialogTitle>
<DialogDescription className="sr-only">Release notes and new features overview</DialogDescription>
<DialogTitle ref={titleRef} className="text-xl">What's new</DialogTitle>
<DialogDescription className="sr-only">
Release highlights for BillTracker v{RELEASE_NOTES.version}
</DialogDescription>
</DialogHeader>
<div className="mt-2 space-y-3" role="list" aria-label="Release highlights">
@ -47,24 +52,15 @@ export function ReleaseNotesDialog() {
<span className="text-lg leading-none mt-0.5" aria-hidden="true">{item.icon}</span>
<div>
<p className="text-sm font-medium text-foreground">{item.title}</p>
<p className="text-xs text-muted-foreground mt-0.5">{item.desc}</p>
<p className="text-xs text-muted-foreground mt-0.5 leading-relaxed">{item.desc}</p>
</div>
</div>
))}
</div>
<div className="mt-4 pt-4 border-t border-border flex items-center justify-between">
<a
href="/legacy"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
aria-label="Access original UI"
>
Access original UI
</a>
<div className="mt-4 pt-4 border-t border-border flex items-center justify-end">
<Button size="sm" onClick={handleClose}>
Get started
Got it
</Button>
</div>
</DialogContent>

View File

@ -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 (
<AuthContext.Provider value={{ user, singleUserMode, setUser, logout, refresh }}>
<AuthContext.Provider value={{
user, singleUserMode, setUser, logout, refresh,
hasNewVersion, setHasNewVersion,
}}>
{children}
</AuthContext.Provider>
);
}
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: () => {},
};
}

View File

@ -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.',
},
],
};

View File

@ -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 (
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,oklch(var(--primary)/0.10),transparent_34rem),linear-gradient(180deg,oklch(var(--background)),oklch(var(--muted)/0.28))] px-4 py-8 text-foreground sm:px-6">
<main className="mx-auto w-full max-w-3xl space-y-5">
<Button asChild variant="ghost" size="sm" className="-ml-2">
<Link to="/login">
<Link to={user ? '/' : '/login'}>
<ArrowLeft className="h-3.5 w-3.5" />
Back
{user ? 'Back to app' : 'Back'}
</Link>
</Button>
<Card className="border-border/70 bg-card/95 shadow-sm">
<Card className="border-border/70 bg-card/95 shadow-sm" id="about-card">
<CardHeader>
<div className="mb-2 flex h-10 w-10 items-center justify-center rounded-xl border border-border/70 bg-primary/10 text-primary">
<Info className="h-5 w-5" />
@ -44,10 +51,33 @@ export default function AboutPage() {
</CardHeader>
<CardContent className="space-y-5">
<div className="grid gap-3 sm:grid-cols-3">
{/* Version — with update status for admins */}
<div className="rounded-xl border border-border/70 bg-background/65 p-4">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Version</p>
<p className="mt-1 font-mono text-lg font-bold">v{about?.version || '...'}</p>
{updateStatus && (
<div className="mt-2">
{updateStatus.has_update ? (
<a
href={updateStatus.latest_release_url || '#'}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs font-medium text-amber-400 hover:text-amber-300 transition-colors"
>
<ArrowUpCircle className="h-3 w-3 shrink-0" />
v{updateStatus.latest_version} available
</a>
) : updateStatus.up_to_date ? (
<span className="inline-flex items-center gap-1 text-xs text-emerald-500/80">
<CheckCircle2 className="h-3 w-3 shrink-0" />
Up to date
</span>
) : null}
</div>
)}
</div>
<div className="rounded-xl border border-border/70 bg-background/65 p-4">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Backend</p>
<p className="mt-1 text-sm font-semibold">{about?.stack?.backend || 'Node.js / Express'}</p>
@ -74,13 +104,30 @@ export default function AboutPage() {
<Button asChild>
<Link to="/release-notes">Release Notes</Link>
</Button>
<Button asChild variant="outline">
<Link to="/login">Sign In</Link>
</Button>
{/* Only shown when the visitor is not signed in */}
{user == null && (
<Button asChild variant="outline">
<Link to="/login">Sign In</Link>
</Button>
)}
</div>
</CardContent>
</Card>
</main>
{/* Easter egg — barely visible, reveals on hover for curious explorers */}
<div className="flex justify-center pt-10 pb-4">
<a
href="/legacy"
target="_blank"
rel="noopener noreferrer"
className="text-[10px] text-muted-foreground/10 hover:text-muted-foreground/50 transition-colors duration-1000 select-none tracking-widest uppercase"
tabIndex={-1}
aria-hidden="true"
>
remember when
</a>
</div>
</div>
);
}
}

View File

@ -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() {
</div>
</div>
{/* 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) && (
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-emerald-400/80">
<span className="font-medium"> Clears {attackProjection.payoff_display}</span>
{attackProjection.total_interest > 0 && (
<span className="text-muted-foreground">· {fmtCompact(attackProjection.total_interest)} interest</span>
<span className="font-medium">
Clears {liveAttackPayoff ?? attackProjection.payoff_display}
</span>
{attackProjection?.total_interest > 0 && (
<span className="text-muted-foreground">
· {fmtCompact(attackProjection.total_interest)} interest
</span>
)}
</div>
)}

View File

@ -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 (
<StatusCard title="Software Update" status={status} tone={tone}>
<div className="flex items-center gap-2 py-2 border-b border-border/50">
<Icon className={cn('h-4 w-4 shrink-0', iconCls)} />
<span className="text-sm font-medium">
{hasUpdate
? `v${update.latest_version} is available`
: isKnown && !hasError
? 'Running the latest version'
: hasError
? 'Could not reach update server'
: 'Status unknown'}
</span>
</div>
<StatRow label="Running" value={update.current_version ? `v${update.current_version}` : null} />
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-sm text-muted-foreground">Latest Release</span>
{update.latest_version ? (
update.latest_release_url ? (
<a
href={update.latest_release_url}
target="_blank"
rel="noopener noreferrer"
className={cn(
'text-sm font-medium underline-offset-2 hover:underline',
hasUpdate ? 'text-amber-500' : '',
)}
>
v{update.latest_version}
</a>
) : (
<span className="text-sm font-medium">v{update.latest_version}</span>
)
) : (
<span className="text-sm font-medium text-muted-foreground"></span>
)}
</div>
<StatRow label="Last Checked" value={formatDateTime(update.last_checked_at)} />
{update.error && (
<div className="py-2 border-b border-border/50">
<p className="text-xs text-red-400 leading-relaxed">{update.error}</p>
</div>
)}
<div className="pt-3">
<Button variant="outline" size="sm" onClick={onCheckNow} disabled={checking}
className="gap-1.5">
<RefreshCw className={cn('h-3 w-3', checking && 'animate-spin')} />
{checking ? 'Checking…' : 'Check Now'}
</Button>
</div>
</StatusCard>
);
}
// 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() {
</div>
<div className="grid gap-4 mb-6 md:grid-cols-2 xl:grid-cols-3">
{updateData && (
<UpdateCard
update={updateData}
onCheckNow={handleCheckNow}
checking={updateChecking}
/>
)}
<StatusCard
title="Daily Worker"
status={workerOk === null ? 'Pending' : workerOk ? 'Running' : 'Stopped'}

View File

@ -732,6 +732,21 @@ function reconcileLegacyMigrations() {
}
console.log('[migration] bills: snowball_exempt column added');
}
},
{
version: 'v0.52',
description: 'users: last_seen_version for release-notes notifications',
check: function() {
const cols = db.prepare('PRAGMA table_info(users)').all().map(c => 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']
}
};

View File

@ -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);

View File

@ -1,6 +1,6 @@
{
"name": "bill-tracker",
"version": "0.27.01",
"version": "0.27.02",
"description": "Monthly bill tracking system",
"main": "server.js",
"scripts": {

View File

@ -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;

View File

@ -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 = ?'

View File

@ -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,

View File

@ -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;

View File

@ -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"
echo "✓ Pushed dev + ${VERSION_TAG} images"

View File

@ -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,
};
}

View File

@ -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<Object>} 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 };

View File

@ -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') },
},