Add quick add-reading homepage, photo take/choose picker, carry-forward estimate rate

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-10-23 11:56:08 +00:00
parent 68dab06b0a
commit 847fe49acd
6 changed files with 180 additions and 7 deletions

View file

@ -167,9 +167,19 @@ export async function getTrailingDailyRate(meterId, windowDays) {
windowStart.setDate(windowStart.getDate() - windowDays) windowStart.setDate(windowStart.getDate() - windowDays)
const windowStartStr = windowStart.toISOString().slice(0, 10) const windowStartStr = windowStart.toISOString().slice(0, 10)
const before = await readingOnOrBefore(meterId, windowStartStr) let before = await readingOnOrBefore(meterId, windowStartStr)
if (!before || before.reading_date >= latest.reading_date) { if (!before || before.reading_date >= latest.reading_date) {
return { daily_rate: null, latest } // Nothing old enough to fill the window (readings are sparse) — fall back
// to the average between the last two readings so the estimate carries
// forward instead of dropping to zero until a new reading is added.
const { rows } = await pool.query(
`SELECT reading_value, reading_date FROM readings
WHERE meter_id = $1 AND reading_date < $2
ORDER BY reading_date DESC, id DESC LIMIT 1`,
[meterId, latest.reading_date]
)
before = rows[0] || null
if (!before) return { daily_rate: null, latest }
} }
const days = daysBetween(before.reading_date, latest.reading_date) const days = daysBetween(before.reading_date, latest.reading_date)
if (days <= 0) return { daily_rate: null, latest } if (days <= 0) return { daily_rate: null, latest }

View file

@ -7,6 +7,7 @@ import Layout from './components/Layout'
import Meters from './pages/Meters' import Meters from './pages/Meters'
import MeterDetail from './pages/MeterDetail' import MeterDetail from './pages/MeterDetail'
import Tariffs from './pages/Tariffs' import Tariffs from './pages/Tariffs'
import AddReading from './pages/AddReading'
import Readings from './pages/Readings' import Readings from './pages/Readings'
import Reports from './pages/Reports' import Reports from './pages/Reports'
import Estimates from './pages/Estimates' import Estimates from './pages/Estimates'
@ -20,7 +21,7 @@ export default function App() {
<AuthGate> <AuthGate>
<Layout> <Layout>
<Routes> <Routes>
<Route path="/" element={<Navigate to="/meters" replace />} /> <Route path="/" element={<AddReading />} />
<Route path="/meters" element={<Meters />} /> <Route path="/meters" element={<Meters />} />
<Route path="/meters/:id" element={<MeterDetail />} /> <Route path="/meters/:id" element={<MeterDetail />} />
<Route path="/readings" element={<Readings />} /> <Route path="/readings" element={<Readings />} />

View file

@ -1,12 +1,13 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { NavLink, useLocation } from 'react-router-dom' import { NavLink, useLocation } from 'react-router-dom'
import { Zap, Gauge, Receipt, BarChart3, TrendingUp, Settings, Menu, LogOut } from 'lucide-react' import { Zap, PlusCircle, Gauge, Receipt, BarChart3, TrendingUp, Settings, Menu, LogOut } from 'lucide-react'
import { useAuth } from './AuthGate' import { useAuth } from './AuthGate'
import { can } from '../types' import { can } from '../types'
const ICON_PROPS = { size: 16, strokeWidth: 1.75 } const ICON_PROPS = { size: 16, strokeWidth: 1.75 }
const NAV = [ const NAV = [
{ to: '/', label: 'Add reading', icon: PlusCircle, cap: 'readings', end: true },
{ to: '/meters', label: 'Meters', icon: Gauge, cap: 'meters' }, { to: '/meters', label: 'Meters', icon: Gauge, cap: 'meters' },
{ to: '/readings', label: 'Readings', icon: Zap, cap: 'readings' }, { to: '/readings', label: 'Readings', icon: Zap, cap: 'readings' },
{ to: '/tariffs', label: 'Tariffs', icon: Receipt, cap: 'tariffs' }, { to: '/tariffs', label: 'Tariffs', icon: Receipt, cap: 'tariffs' },
@ -37,7 +38,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
</div> </div>
<nav className="sidebar-nav"> <nav className="sidebar-nav">
{items.map(({ to, label, icon: Icon }) => ( {items.map(({ to, label, icon: Icon }) => (
<NavLink key={to} to={to} className={({ isActive }) => isActive ? 'active' : ''}> <NavLink key={to} to={to} end={to === '/'} className={({ isActive }) => isActive ? 'active' : ''}>
<Icon {...ICON_PROPS} /> <Icon {...ICON_PROPS} />
{label} {label}
</NavLink> </NavLink>

View file

@ -0,0 +1,38 @@
import { useRef } from 'react'
import { Camera, Image as ImageIcon } from 'lucide-react'
interface Props {
file: File | null
onChange: (file: File | null) => void
}
// Two inputs: one opens the gallery/file chooser, the other opens the camera on mobile.
export default function PhotoPicker({ file, onChange }: Props) {
const chooseRef = useRef<HTMLInputElement>(null)
const cameraRef = useRef<HTMLInputElement>(null)
const pick = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0]
if (f) onChange(f)
e.target.value = ''
}
return (
<div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button type="button" className="btn btn-sm" onClick={() => cameraRef.current?.click()}>
<Camera size={13} strokeWidth={1.75} /> Take photo
</button>
<button type="button" className="btn btn-sm" onClick={() => chooseRef.current?.click()}>
<ImageIcon size={13} strokeWidth={1.75} /> Choose image
</button>
</div>
<input ref={cameraRef} type="file" accept="image/*" capture="environment" onChange={pick} style={{ display: 'none' }} />
<input ref={chooseRef} type="file" accept="image/*" onChange={pick} style={{ display: 'none' }} />
{file && (
<div className="field-hint" style={{ marginTop: 6 }}>
{file.name}{' '}
<button type="button" className="btn btn-sm" onClick={() => onChange(null)}>Remove</button>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,122 @@
import { useCallback, useEffect, useState } from 'react'
import { Navigate } from 'react-router-dom'
import { X } from 'lucide-react'
import { useAuth } from '../components/AuthGate'
import PhotoPicker from '../components/PhotoPicker'
import { can, CATEGORY_DOT_CLASS } from '../types'
import type { Meter } from '../types'
import * as api from '../api'
export default function AddReading() {
const { user } = useAuth()
const canEnter = can(user, 'readings')
const [meters, setMeters] = useState<Meter[]>([])
const [active, setActive] = useState<Meter | null>(null)
const [value, setValue] = useState('')
const [date, setDate] = useState(new Date().toISOString().slice(0, 10))
const [photo, setPhoto] = useState<File | null>(null)
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const load = useCallback(() => {
api.fetchMeters({ active: true }).then(setMeters).catch(err => setError(err.message))
}, [])
useEffect(() => { load() }, [load])
if (!canEnter) return <Navigate to="/meters" replace />
function open(m: Meter) {
setActive(m)
setValue('')
setPhoto(null)
setDate(new Date().toISOString().slice(0, 10))
setError(null)
}
async function submit() {
if (!active || value === '') { setError('Reading value is required'); return }
setSaving(true)
setError(null)
try {
const r = await api.createReading({ meter_id: active.id, reading_value: parseFloat(value), reading_date: date })
if (photo) await api.uploadReadingPhoto(r.id, photo)
setInfo(r.warning ? `${active.name}: ${r.warning}` : `Saved reading for ${active.name}`)
setActive(null)
load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Save failed')
} finally {
setSaving(false)
}
}
return (
<div className="page">
<div className="page-header"><h1>Add reading</h1></div>
{error && !active && <div className="error-banner">{error}</div>}
{info && <div className="info-banner">{info}</div>}
{meters.length === 0 ? (
<div className="empty-state">No active meters.</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: 12 }}>
{meters.map(m => (
<button
key={m.id}
className="card"
onClick={() => open(m)}
style={{ textAlign: 'left', cursor: 'pointer', marginBottom: 0 }}
>
<div style={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 6 }}>
<span className={`cat-dot ${CATEGORY_DOT_CLASS[m.category_key] || ''}`} />{m.name}
</div>
<div className="field-hint">{m.category_name}{m.location ? ` · ${m.location}` : ''}</div>
<div className="field-hint" style={{ marginTop: 6 }}>
{m.latest_reading_value
? `${Number(m.latest_reading_value).toLocaleString()} ${m.reading_unit_label} · ${new Date(m.latest_reading_date!).toLocaleDateString('en-GB')}`
: 'No readings yet'}
</div>
</button>
))}
</div>
)}
{active && (
<div className="modal-overlay" onClick={() => setActive(null)}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>{active.name}</h2>
<button className="modal-close" onClick={() => setActive(null)}><X size={18} strokeWidth={1.75} /></button>
</div>
{error && <div className="error-banner">{error}</div>}
<div className="field">
<label>Reading ({active.reading_unit_label})</label>
<input type="number" step="0.001" inputMode="decimal" autoFocus value={value}
onChange={e => setValue(e.target.value)} placeholder="e.g. 45231.5" />
{active.latest_reading_value && (
<div className="field-hint">
Previous: {Number(active.latest_reading_value).toLocaleString()} {active.reading_unit_label} on {new Date(active.latest_reading_date!).toLocaleDateString('en-GB')}
</div>
)}
</div>
<div className="field">
<label>Date</label>
<input type="date" value={date} onChange={e => setDate(e.target.value)} />
</div>
<div className="field">
<label>Photo</label>
<PhotoPicker file={photo} onChange={setPhoto} />
</div>
<div className="modal-actions">
<button className="btn" onClick={() => setActive(null)}>Cancel</button>
<button className="btn btn-primary" onClick={submit} disabled={saving}>{saving ? 'Saving…' : 'Save reading'}</button>
</div>
</div>
</div>
)}
</div>
)
}

View file

@ -3,6 +3,7 @@ import { Camera, Trash2 } from 'lucide-react'
import { useAuth } from '../components/AuthGate' import { useAuth } from '../components/AuthGate'
import { can } from '../types' import { can } from '../types'
import type { Meter, Reading } from '../types' import type { Meter, Reading } from '../types'
import PhotoPicker from '../components/PhotoPicker'
import * as api from '../api' import * as api from '../api'
export default function Readings() { export default function Readings() {
@ -106,7 +107,7 @@ export default function Readings() {
</div> </div>
<div className="field" style={{ marginBottom: 0 }}> <div className="field" style={{ marginBottom: 0 }}>
<label>Photo</label> <label>Photo</label>
<input type="file" accept="image/*" onChange={e => setPhoto(e.target.files?.[0] || null)} style={{ fontSize: 11 }} /> <PhotoPicker file={photo} onChange={setPhoto} />
</div> </div>
<button className="btn btn-primary" onClick={submit} disabled={saving} style={{ marginBottom: 1 }}> <button className="btn btn-primary" onClick={submit} disabled={saving} style={{ marginBottom: 1 }}>
{saving ? 'Saving…' : 'Add reading'} {saving ? 'Saving…' : 'Add reading'}
@ -142,7 +143,7 @@ export default function Readings() {
<a href={api.uploadUrl(r.photo_path)} target="_blank" rel="noreferrer">View</a> <a href={api.uploadUrl(r.photo_path)} target="_blank" rel="noreferrer">View</a>
) : canEnter ? ( ) : canEnter ? (
photoTargetId === r.id ? ( photoTargetId === r.id ? (
<input type="file" accept="image/*" autoFocus onChange={e => e.target.files?.[0] && uploadPhoto(r.id, e.target.files[0])} /> <PhotoPicker file={null} onChange={f => f && uploadPhoto(r.id, f)} />
) : ( ) : (
<button className="btn btn-sm" onClick={() => setPhotoTargetId(r.id)}><Camera size={13} strokeWidth={1.75} /></button> <button className="btn btn-sm" onClick={() => setPhotoTargetId(r.id)}><Camera size={13} strokeWidth={1.75} /></button>
) )