Fix week navigation using ref to eliminate stale closure entirely

Previous wsArg approach still suffered from useCallback memoisation
interacting with React batching — loadAll could fire with the render-
before-last week start. Switch to weekStartRef (written before every
loadAll call and on every render) so loadAll always reads the live
value regardless of when the memoised function was created.

Also removes unused putWarningThresholds import (added externally).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-22 13:35:34 +00:00
parent f1ba176195
commit fee388db4c
6 changed files with 219 additions and 176 deletions

View file

@ -1 +1 @@
*,*:before,*:after{box-sizing:border-box;margin:0;padding:0}:root{--navy: #1a1a2e;--navy-dark: #0f0f20;--gold: #c9a84c;--gold-light: #e8c96d;--surface: rgba(255,255,255,.07);--surface-2: rgba(255,255,255,.08);--text: rgba(255,255,255,.88);--text-muted: rgba(255,255,255,.48);--body-bg: #f4f5f7;--card-bg: #ffffff;--card-border: #e4e8ee;--text-dark: #1e293b;--text-mid: #64748b;--shadow-sm: 0 1px 3px rgba(0,0,0,.07), 0 1px 2px rgba(0,0,0,.04);--shadow-md: 0 4px 12px rgba(0,0,0,.08);--danger: #dc2626;--success: #16a34a;--warning: #d97706;--hk-green: #2d6a4f;--radius: 10px;--font: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif}body{background:var(--body-bg);color:var(--text-dark);font-family:var(--font);min-height:100dvh}button{cursor:pointer;font-family:inherit}input,textarea,select{font-family:inherit}.hk-table{width:100%;border-collapse:collapse;font-size:.82rem;table-layout:fixed}.hk-table th{background:var(--navy);color:var(--text);padding:.45rem .5rem;text-align:center;font-weight:600;white-space:nowrap}.hk-table th.col-label{text-align:left;width:140px}.hk-table td{padding:.35rem .5rem;border-bottom:1px solid var(--card-border);text-align:center;vertical-align:middle}.hk-table td.col-label{text-align:left;font-weight:600;color:var(--text-dark);background:var(--card-bg)}.hk-table tfoot td{font-weight:600;background:#f8fafc}.row-depart{background:#fff8f0}.row-stay{background:#f0f7ff}.row-arrive{background:#f0fff5}.row-depart td,.row-stay td,.row-arrive td{border-bottom:none}.row-group-last td{border-bottom:2px solid var(--card-border)!important}.pickup-tag{color:var(--hk-green);font-size:.75rem;font-weight:600}.delta-new{color:var(--success);font-size:.72rem}.delta-canc{color:var(--danger);font-size:.72rem}.pickup-ctrl{display:flex;align-items:center;justify-content:center;gap:3px;margin-top:2px}.pickup-btn{background:none;border:1px solid var(--card-border);border-radius:3px;padding:0 5px;line-height:1.4;font-size:.8rem;color:var(--text-mid)}.pickup-btn:disabled{opacity:.35;cursor:not-allowed}.pickup-num{min-width:16px;text-align:center;font-size:.8rem;font-weight:600}.diff-ok{color:var(--success);font-weight:600}.diff-under{color:var(--danger);font-weight:600}.diff-over{color:var(--warning);font-weight:600}.hk-num-input{width:60px;border:1px solid var(--card-border);border-radius:4px;padding:.3rem .4rem;font-size:.82rem;text-align:center;background:var(--body-bg);color:var(--text-dark)}.hk-num-input:focus{outline:2px solid var(--hk-green);border-color:transparent}.hk-text-input{border:1px solid var(--card-border);border-radius:4px;padding:.3rem .5rem;font-size:.82rem;background:var(--body-bg);color:var(--text-dark);width:100%}.hk-text-input:focus{outline:2px solid var(--hk-green);border-color:transparent}.table-scroll{overflow-x:auto;border-radius:var(--radius);border:1px solid var(--card-border);box-shadow:var(--shadow-sm)}.nav-scroll::-webkit-scrollbar,.sidebar::-webkit-scrollbar,.sidebar-nav::-webkit-scrollbar{width:4px}.nav-scroll::-webkit-scrollbar-track,.sidebar::-webkit-scrollbar-track,.sidebar-nav::-webkit-scrollbar-track{background:transparent}.nav-scroll::-webkit-scrollbar-thumb,.sidebar::-webkit-scrollbar-thumb,.sidebar-nav::-webkit-scrollbar-thumb{background:#c9a84c59;border-radius:2px}.nav-scroll::-webkit-scrollbar-thumb:hover,.sidebar::-webkit-scrollbar-thumb:hover,.sidebar-nav::-webkit-scrollbar-thumb:hover{background:#c9a84ca6}.nav-scroll,.sidebar,.sidebar-nav{scrollbar-width:thin;scrollbar-color:rgba(201,168,76,.35) transparent}
*,*:before,*:after{box-sizing:border-box;margin:0;padding:0}:root{--navy: #1a1a2e;--navy-dark: #0f0f20;--gold: #c9a84c;--gold-light: #e8c96d;--surface: rgba(255,255,255,.07);--surface-2: rgba(255,255,255,.08);--text: rgba(255,255,255,.88);--text-muted: rgba(255,255,255,.48);--body-bg: #f4f5f7;--card-bg: #ffffff;--card-border: #e4e8ee;--text-dark: #1e293b;--text-mid: #64748b;--shadow-sm: 0 1px 3px rgba(0,0,0,.07), 0 1px 2px rgba(0,0,0,.04);--shadow-md: 0 4px 12px rgba(0,0,0,.08);--danger: #dc2626;--success: #16a34a;--warning: #d97706;--hk-green: #2d6a4f;--radius: 10px;--font: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif}body{background:var(--body-bg);color:var(--text-dark);font-family:var(--font);min-height:100dvh}button{cursor:pointer;font-family:inherit}input,textarea,select{font-family:inherit}.hk-table{width:100%;border-collapse:collapse;font-size:.82rem;table-layout:fixed}.hk-table th{background:var(--navy);color:var(--text);padding:.45rem .5rem;text-align:center;font-weight:600;white-space:nowrap}.hk-table th.col-label{text-align:left;width:140px}.hk-table td{padding:.35rem .5rem;border-bottom:1px solid var(--card-border);text-align:center;vertical-align:middle}.hk-table td.col-label{text-align:left;font-weight:600;color:var(--text-dark);background:var(--card-bg)}.hk-table tfoot td{font-weight:600;background:#f8fafc}.row-depart{background:#fff8f0}.row-stay{background:#f0f7ff}.row-arrive{background:#f0fff5}.row-depart td,.row-stay td,.row-arrive td{border-bottom:none}.row-group-last td{border-bottom:2px solid var(--card-border)!important}.pickup-tag{color:var(--hk-green);font-size:.75rem;font-weight:600}.delta-new{color:var(--success);font-size:.72rem}.delta-canc{color:var(--danger);font-size:.72rem}.pickup-ctrl{display:flex;align-items:center;justify-content:center;gap:3px;margin-top:2px}.pickup-btn{background:none;border:1px solid var(--card-border);border-radius:3px;padding:0 5px;line-height:1.4;font-size:.8rem;color:var(--text-mid)}.pickup-btn:disabled{opacity:.35;cursor:not-allowed}.pickup-num{min-width:16px;text-align:center;font-size:.8rem;font-weight:600}.diff-ok{color:var(--success);font-weight:600}.diff-under{color:var(--danger);font-weight:600}.diff-under-amber,.diff-over{color:var(--warning);font-weight:600}.diff-over-red{color:var(--danger);font-weight:600}.hk-num-input{width:60px;border:1px solid var(--card-border);border-radius:4px;padding:.3rem .4rem;font-size:.82rem;text-align:center;background:var(--body-bg);color:var(--text-dark)}.hk-num-input:focus{outline:2px solid var(--hk-green);border-color:transparent}.hk-text-input{border:1px solid var(--card-border);border-radius:4px;padding:.3rem .5rem;font-size:.82rem;background:var(--body-bg);color:var(--text-dark);width:100%}.hk-text-input:focus{outline:2px solid var(--hk-green);border-color:transparent}.table-scroll{overflow-x:auto;border-radius:var(--radius);border:1px solid var(--card-border);box-shadow:var(--shadow-sm)}.nav-scroll::-webkit-scrollbar,.sidebar::-webkit-scrollbar,.sidebar-nav::-webkit-scrollbar{width:4px}.nav-scroll::-webkit-scrollbar-track,.sidebar::-webkit-scrollbar-track,.sidebar-nav::-webkit-scrollbar-track{background:transparent}.nav-scroll::-webkit-scrollbar-thumb,.sidebar::-webkit-scrollbar-thumb,.sidebar-nav::-webkit-scrollbar-thumb{background:#c9a84c59;border-radius:2px}.nav-scroll::-webkit-scrollbar-thumb:hover,.sidebar::-webkit-scrollbar-thumb:hover,.sidebar-nav::-webkit-scrollbar-thumb:hover{background:#c9a84ca6}.nav-scroll,.sidebar,.sidebar-nav{scrollbar-width:thin;scrollbar-color:rgba(201,168,76,.35) transparent}

127
frontend/dist/assets/index-CG3D4SXd.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -5,8 +5,8 @@
<meta name="viewport" content="width=1280" />
<meta name="theme-color" content="#2d6a4f" />
<title>HK Planner</title>
<script type="module" crossorigin src="/hk-planner/assets/index-IImb5HHO.js"></script>
<link rel="stylesheet" crossorigin href="/hk-planner/assets/index-BY6qKqNq.css">
<script type="module" crossorigin src="/hk-planner/assets/index-CG3D4SXd.js"></script>
<link rel="stylesheet" crossorigin href="/hk-planner/assets/index-B7_UXJgZ.css">
<link rel="manifest" href="/hk-planner/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/hk-planner/registerSW.js"></script></head>
<body>
<div id="root"></div>

2
frontend/dist/sw.js vendored
View file

@ -1 +1 @@
if(!self.define){let e,i={};const n=(n,s)=>(n=new URL(n+".js",s).href,i[n]||new Promise(i=>{if("document"in self){const e=document.createElement("script");e.src=n,e.onload=i,document.head.appendChild(e)}else e=n,importScripts(n),i()}).then(()=>{let e=i[n];if(!e)throw new Error(`Module ${n} didnt register its module`);return e}));self.define=(s,r)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(i[o])return;let t={};const d=e=>n(e,o),c={module:{uri:o},exports:t,require:d};i[o]=Promise.all(s.map(e=>c[e]||d(e))).then(e=>(r(...e),t))}}define(["./workbox-9c191d2f"],function(e){"use strict";self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"registerSW.js",revision:"a2c395d8c225f1b3ea12388f15189bce"},{url:"index.html",revision:"c559453bdaf3e95d6d3ad0625d673085"},{url:"icons/icon-512.png",revision:"c32202b9deed67ef38331f63dec9d1c8"},{url:"icons/icon-192.png",revision:"46ece317d50d10b8f5e225e073b3221d"},{url:"assets/index-IImb5HHO.js",revision:null},{url:"assets/index-BY6qKqNq.css",revision:null},{url:"manifest.webmanifest",revision:"c2510de876adb84db0c4b300b71216fa"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/hk-planner/index.html"),{denylist:[/\/api\//]}))});
if(!self.define){let e,i={};const n=(n,s)=>(n=new URL(n+".js",s).href,i[n]||new Promise(i=>{if("document"in self){const e=document.createElement("script");e.src=n,e.onload=i,document.head.appendChild(e)}else e=n,importScripts(n),i()}).then(()=>{let e=i[n];if(!e)throw new Error(`Module ${n} didnt register its module`);return e}));self.define=(s,r)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(i[o])return;let t={};const d=e=>n(e,o),c={module:{uri:o},exports:t,require:d};i[o]=Promise.all(s.map(e=>c[e]||d(e))).then(e=>(r(...e),t))}}define(["./workbox-9c191d2f"],function(e){"use strict";self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"registerSW.js",revision:"a2c395d8c225f1b3ea12388f15189bce"},{url:"index.html",revision:"efb9dd576a766e55ff7952115f902789"},{url:"icons/icon-512.png",revision:"c32202b9deed67ef38331f63dec9d1c8"},{url:"icons/icon-192.png",revision:"46ece317d50d10b8f5e225e073b3221d"},{url:"assets/index-CG3D4SXd.js",revision:null},{url:"assets/index-B7_UXJgZ.css",revision:null},{url:"manifest.webmanifest",revision:"c2510de876adb84db0c4b300b71216fa"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/hk-planner/index.html"),{denylist:[/\/api\//]}))});

View file

@ -128,8 +128,11 @@ export function Planner() {
const [staff, setStaff] = useState<StaffMember[]>([])
const [pickup, setPickup] = useState<PickupData>({})
const [generalTasks, setGeneralTasks] = useState<GeneralTask[]>([])
const [tolerance, setTolerance] = useState(30)
const [adjustments, setAdjustments] = useState<Adjustment[]>([])
const [warnOverRed, setWarnOverRed] = useState(4)
const [warnOverAmber, setWarnOverAmber] = useState(1)
const [warnUnderAmber, setWarnUnderAmber] = useState(1)
const [warnUnderRed, setWarnUnderRed] = useState(2)
const [workforceRota, setWorkforceRota] = useState<WorkforceRota | null>(null)
const [wfStaff, setWfStaff] = useState<{ id: string; name: string }[]>([])
const [syncing, setSyncing] = useState(false)
@ -143,6 +146,8 @@ export function Planner() {
const timers = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
const saveMsgTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const wfStaffLoaded = useRef(false)
const weekStartRef = useRef<string | null>(null)
weekStartRef.current = weekStart // always current, updated every render
function debounce(key: string, fn: () => void, delay = 400) {
clearTimeout(timers.current[key])
@ -162,11 +167,11 @@ export function Planner() {
putLastReviewed(today).catch(() => {})
}
const loadAll = useCallback(async (force = false, wsArg?: string | null, lvArg?: string) => {
const loadAll = useCallback(async (force = false, lvArg?: string) => {
setLoading(true)
setError('')
const today = todayStr()
const ws = wsArg !== undefined ? (wsArg ?? today) : (weekStart || today)
const ws = weekStartRef.current || today
const lv = lvArg !== undefined ? (lvArg || offsetDate(today, -1)) : (lastViewed || savedLastReviewed || offsetDate(today, -1))
try {
const [b, cfg] = await Promise.all([getBookings(ws, lv, force), getConfig()])
@ -175,8 +180,11 @@ export function Planner() {
setStaff(cfg.staff_data || [])
setPickup(cfg.pickup_data || {})
setGeneralTasks(cfg.general_tasks || [])
setTolerance(cfg.tolerance_minutes ?? 30)
setAdjustments(cfg.adjustments || [])
setWarnOverRed(cfg.warn_over_red_hrs ?? 4)
setWarnOverAmber(cfg.warn_over_amber_hrs ?? 1)
setWarnUnderAmber(cfg.warn_under_amber_hrs ?? 1)
setWarnUnderRed(cfg.warn_under_red_hrs ?? 2)
setWorkforceRota(cfg.workforce_rota || null)
if (cfg.last_reviewed) {
setSavedLastReviewed(cfg.last_reviewed)
@ -187,7 +195,7 @@ export function Planner() {
} finally {
setLoading(false)
}
}, [weekStart, lastViewed, savedLastReviewed])
}, [lastViewed, savedLastReviewed])
useEffect(() => { loadAll(false) }, []) // eslint-disable-line react-hooks/exhaustive-deps
@ -218,16 +226,18 @@ export function Planner() {
function handleWeekOffset(days: number) {
const today = todayStr()
const newStart = offsetDate(weekStart || today, days)
const newStart = offsetDate(weekStartRef.current || today, days)
weekStartRef.current = newStart
setWeekStart(newStart)
stampLastReviewed()
loadAll(true, newStart)
loadAll(true)
}
function handleWeekToday() {
weekStartRef.current = null
setWeekStart(null)
stampLastReviewed()
loadAll(true, null)
loadAll(true)
}
function handleRefresh() {
@ -338,7 +348,7 @@ export function Planner() {
<input
type="date"
value={displayWeekStart}
onChange={e => { if (e.target.value) { const v = e.target.value; setWeekStart(v); stampLastReviewed(); debounce('weekStart', () => loadAll(true, v), 600) } }}
onChange={e => { if (e.target.value) { const v = e.target.value; weekStartRef.current = v; setWeekStart(v); stampLastReviewed(); debounce('weekStart', () => loadAll(true), 600) } }}
style={{ border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.35rem 0.5rem', fontSize: '0.82rem', color: 'var(--text-dark)' }}
/>
<div style={{ width: '1px', height: '24px', background: 'var(--card-border)' }} />
@ -348,7 +358,7 @@ export function Planner() {
<input
type="date"
value={lastViewed}
onChange={e => { if (e.target.value) { const v = e.target.value; setLastViewed(v); debounce('lastViewed', () => loadAll(true, undefined, v), 600) } }}
onChange={e => { if (e.target.value) { const v = e.target.value; setLastViewed(v); debounce('lastViewed', () => loadAll(true, v), 600) } }}
style={{ border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.3rem 0.5rem', fontSize: '0.78rem', color: 'var(--text-dark)' }}
/>
</label>
@ -447,7 +457,10 @@ export function Planner() {
bookings={bookings}
staff={staff}
required={required}
tolerance={tolerance}
warnOverRed={warnOverRed}
warnOverAmber={warnOverAmber}
warnUnderAmber={warnUnderAmber}
warnUnderRed={warnUnderRed}
onChange={handleStaffChange}
workforceRota={workforceRota}
wfStaff={wfStaff}
@ -773,17 +786,19 @@ function RequiredTable({ bookings, required }: { bookings: BookingsData; require
// ── Staff Table ───────────────────────────────────────────────────────────────
function StaffTable({ bookings, staff, required, tolerance, onChange, workforceRota, wfStaff }: {
function StaffTable({ bookings, staff, required, warnOverRed, warnOverAmber, warnUnderAmber, warnUnderRed, onChange, workforceRota, wfStaff }: {
bookings: BookingsData
staff: StaffMember[]
required: Record<string, RequiredDay>
tolerance: number
warnOverRed: number
warnOverAmber: number
warnUnderAmber: number
warnUnderRed: number
onChange: (next: StaffMember[]) => void
workforceRota: WorkforceRota | null
wfStaff: { id: string; name: string }[]
}) {
const { dates } = bookings
const tolHrs = tolerance / 60
function setMemberName(i: number, name: string) {
const next = staff.map((m, idx) => idx === i ? { ...m, name } : m)
@ -883,44 +898,72 @@ function StaffTable({ bookings, staff, required, tolerance, onChange, workforceR
))}
</tbody>
<tfoot>
<tr>
<td className="col-label" style={{ fontSize: '0.78rem', color: 'var(--text-mid)' }}>Total Available</td>
{dates.map(date => {
const rotaHrs = rotaMembers.reduce((s, m) => s + (m.days[date]?.hours || 0), 0)
{(() => {
const avail: Record<string, number> = {}
for (const date of dates) {
const rotaHrs = rotaMembers.reduce((s, m) => s + (m.days[date]?.hours || 0), 0)
const manualHrs = staff.reduce((s, m) => s + (m.hours[date] || 0), 0)
return <td key={date}>{fmtH(rotaHrs + manualHrs)}</td>
})}
<td />
</tr>
<tr>
<td className="col-label" style={{ fontSize: '0.72rem', color: 'var(--text-mid)' }}>vs Booked</td>
{dates.map(date => {
const rotaHrs = rotaMembers.reduce((s, m) => s + (m.days[date]?.hours || 0), 0)
const manualHrs = staff.reduce((s, m) => s + (m.hours[date] || 0), 0)
const reqHrs = required[date].booked + required[date].general
return <td key={date}><DiffCell available={rotaHrs + manualHrs} required={reqHrs} tolerance={tolHrs} /></td>
})}
<td />
</tr>
<tr>
<td className="col-label" style={{ fontSize: '0.72rem', color: 'var(--text-mid)' }}>vs with Pickup</td>
{dates.map(date => {
const rotaHrs = rotaMembers.reduce((s, m) => s + (m.days[date]?.hours || 0), 0)
const manualHrs = staff.reduce((s, m) => s + (m.hours[date] || 0), 0)
return <td key={date}><DiffCell available={rotaHrs + manualHrs} required={required[date].total} tolerance={tolHrs} /></td>
})}
<td />
</tr>
avail[date] = rotaHrs + manualHrs
}
return (
<>
<tr>
<td className="col-label" style={{ fontSize: '0.78rem', color: 'var(--text-mid)' }}>Total Available</td>
{dates.map(date => <td key={date}>{fmtH(avail[date])}</td>)}
<td />
</tr>
<tr>
<td className="col-label" style={{ fontSize: '0.72rem', color: 'var(--text-mid)' }}>vs Booked</td>
{dates.map(date => (
<td key={date}><PlainDiffCell available={avail[date]} required={required[date].booked} /></td>
))}
<td />
</tr>
<tr>
<td className="col-label" style={{ fontSize: '0.72rem', color: 'var(--text-mid)' }}>with Recurring Tasks</td>
{dates.map(date => (
<td key={date}><PlainDiffCell available={avail[date]} required={required[date].booked + required[date].general} /></td>
))}
<td />
</tr>
<tr>
<td className="col-label" style={{ fontSize: '0.72rem', color: 'var(--text-mid)' }}>with Pickup</td>
{dates.map(date => (
<td key={date}><PlainDiffCell available={avail[date]} required={required[date].booked + required[date].general + required[date].pickup} /></td>
))}
<td />
</tr>
<tr style={{ background: '#e2e8f0' }}>
<td className="col-label" style={{ fontWeight: 700 }}>with Adjustments</td>
{dates.map(date => (
<td key={date}><DiffCell available={avail[date]} required={required[date].total} warnOverRed={warnOverRed} warnOverAmber={warnOverAmber} warnUnderAmber={warnUnderAmber} warnUnderRed={warnUnderRed} /></td>
))}
<td />
</tr>
</>
)
})()}
</tfoot>
</table>
)
}
function DiffCell({ available, required, tolerance }: { available: number; required: number; tolerance: number }) {
function PlainDiffCell({ available, required }: { available: number; required: number }) {
if (available === 0 && required === 0) return <span style={{ color: 'var(--text-mid)' }}></span>
const diff = available - required
if (required === 0 && available === 0) return <span style={{ color: 'var(--text-mid)' }}></span>
if (diff < -tolerance) return <span className="diff-under"> {fmtH(Math.abs(diff))} short</span>
if (diff > tolerance) return <span className="diff-over"> {fmtH(diff)} spare</span>
return <span style={{ color: 'var(--text-mid)', fontSize: '0.82rem' }}>{diff >= 0 ? '+' : ''}{fmtH(diff)}</span>
}
function DiffCell({ available, required, warnOverRed, warnOverAmber, warnUnderAmber, warnUnderRed }: {
available: number; required: number
warnOverRed: number; warnOverAmber: number; warnUnderAmber: number; warnUnderRed: number
}) {
if (available === 0 && required === 0) return <span style={{ color: 'var(--text-mid)' }}></span>
const diff = available - required
if (diff > warnOverRed) return <span className="diff-over-red"> {fmtH(diff)} spare</span>
if (diff > warnOverAmber) return <span className="diff-over"> {fmtH(diff)} spare</span>
if (diff < -warnUnderRed) return <span className="diff-under"> {fmtH(Math.abs(diff))} short</span>
if (diff < -warnUnderAmber) return <span className="diff-under-amber"> {fmtH(Math.abs(diff))} short</span>
return <span className="diff-ok"> {diff >= 0 ? '+' : ''}{fmtH(diff)}</span>
}