Scaffold Phase 1 hvac app — NewBook-driven room TRV heating scheduler
Ports the state machine, retry/backoff, and guest-override detection from the retired homeassistant-newbook-heating-component, without depending on Home Assistant. Backend (Fastify/pg) + frontend (React/Vite/TS) following standard stack conventions; LXC 128 (127 was already taken by utilities). MHI/Midea/Daikin/boiler drivers and the shared MQTT broker (LXC 104) are later phases/infra, not included here. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
276c04f8c6
42 changed files with 5461 additions and 0 deletions
80
frontend/src/pages/Dashboard.tsx
Normal file
80
frontend/src/pages/Dashboard.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { WifiOff } from 'lucide-react'
|
||||
import { fetchStatus } from '../api'
|
||||
import type { ZoneStatus } from '../types'
|
||||
import ZoneCard from '../components/ZoneCard'
|
||||
import ZoneDetailModal from '../components/ZoneDetailModal'
|
||||
|
||||
const POLL_MS = 30000
|
||||
|
||||
export default function Dashboard() {
|
||||
const [zones, setZones] = useState<ZoneStatus[]>([])
|
||||
const [mqttConnected, setMqttConnected] = useState(true)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [selected, setSelected] = useState<ZoneStatus | null>(null)
|
||||
|
||||
const load = useCallback(() => {
|
||||
fetchStatus()
|
||||
.then(res => {
|
||||
setZones(res.zones)
|
||||
setMqttConnected(res.mqtt_connected)
|
||||
setError('')
|
||||
})
|
||||
.catch(e => setError(e instanceof Error ? e.message : 'Failed to load status'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
const t = setInterval(load, POLL_MS)
|
||||
return () => clearInterval(t)
|
||||
}, [load])
|
||||
|
||||
const rooms = zones.filter(z => z.zone_type === 'room')
|
||||
const publicAreas = zones.filter(z => z.zone_type === 'public_area')
|
||||
|
||||
if (loading) return <div className="page"><p className="muted">Loading…</p></div>
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1>Dashboard</h1>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
{!mqttConnected && (
|
||||
<div className="error-banner" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<WifiOff size={14} strokeWidth={1.75} />
|
||||
MQTT broker not connected — device status may be stale and setpoint changes won't reach TRVs.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="section-title">Rooms</div>
|
||||
{rooms.length === 0 ? (
|
||||
<div className="empty-state">No room zones yet — sync from NewBook on the Settings page.</div>
|
||||
) : (
|
||||
<div className="zone-grid">
|
||||
{rooms.map(z => <ZoneCard key={z.id} zone={z} onClick={() => setSelected(z)} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{publicAreas.length > 0 && (
|
||||
<>
|
||||
<div className="section-title">Public Areas</div>
|
||||
<div className="zone-grid">
|
||||
{publicAreas.map(z => <ZoneCard key={z.id} zone={z} onClick={() => setSelected(z)} />)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<ZoneDetailModal
|
||||
zone={selected}
|
||||
onClose={() => setSelected(null)}
|
||||
onSaved={() => { load(); setSelected(null) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
193
frontend/src/pages/Devices.tsx
Normal file
193
frontend/src/pages/Devices.tsx
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import { Fragment, useEffect, useState } from 'react'
|
||||
import { RadioTower, ChevronDown, ChevronRight, Wrench } from 'lucide-react'
|
||||
import {
|
||||
fetchDevices, discoverDevices, updateDevice, fetchZones, fetchDevicePhotos, createMaintenanceAsset,
|
||||
} from '../api'
|
||||
import type { Device, Zone, DevicePhoto, DeviceType } from '../types'
|
||||
import { DEVICE_TYPE_LABELS, IMPLEMENTED_DEVICE_TYPES } from '../types'
|
||||
import DevicePhotoUpload from '../components/DevicePhotoUpload'
|
||||
import { useAuth } from '../components/AuthGate'
|
||||
import { can } from '../types'
|
||||
|
||||
const ALL_DEVICE_TYPES: DeviceType[] = ['shelly_trv', 'mhi_modbus', 'midea', 'daikin', 'home_assistant']
|
||||
|
||||
export default function Devices() {
|
||||
const { user } = useAuth()
|
||||
const canManage = can(user, 'manage_devices')
|
||||
|
||||
const [devices, setDevices] = useState<Device[]>([])
|
||||
const [zones, setZones] = useState<Zone[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [msg, setMsg] = useState('')
|
||||
const [discovering, setDiscovering] = useState<DeviceType | null>(null)
|
||||
const [expanded, setExpanded] = useState<number | null>(null)
|
||||
const [photosByDevice, setPhotosByDevice] = useState<Record<number, DevicePhoto[]>>({})
|
||||
|
||||
function load() {
|
||||
Promise.all([fetchDevices(), fetchZones()])
|
||||
.then(([d, z]) => { setDevices(d); setZones(z) })
|
||||
.catch(e => setError(e instanceof Error ? e.message : 'Failed to load'))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
useEffect(load, [])
|
||||
|
||||
async function runDiscover(deviceType: DeviceType) {
|
||||
setDiscovering(deviceType); setError(''); setMsg('')
|
||||
try {
|
||||
const res = await discoverDevices(deviceType)
|
||||
if (res.note) setMsg(res.note)
|
||||
else if (res.ok) setMsg(`Found ${res.found ?? 0} device(s), ${res.new ?? 0} new`)
|
||||
else setMsg(res.error || 'Discovery returned no result')
|
||||
load()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Discovery failed')
|
||||
} finally {
|
||||
setDiscovering(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function assign(id: number, zoneId: number | null, location: string) {
|
||||
try {
|
||||
await updateDevice(id, { zone_id: zoneId, location })
|
||||
load()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Save failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleExpand(id: number) {
|
||||
if (expanded === id) { setExpanded(null); return }
|
||||
setExpanded(id)
|
||||
if (!photosByDevice[id]) {
|
||||
const photos = await fetchDevicePhotos(id).catch(() => [])
|
||||
setPhotosByDevice(prev => ({ ...prev, [id]: photos }))
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadPhotos(id: number) {
|
||||
const photos = await fetchDevicePhotos(id).catch(() => [])
|
||||
setPhotosByDevice(prev => ({ ...prev, [id]: photos }))
|
||||
}
|
||||
|
||||
async function createAsset(id: number) {
|
||||
const res = await createMaintenanceAsset(id).catch(e => ({ ok: false, error: e.message }))
|
||||
setMsg(res.error || (res.ok ? 'Asset created' : 'Not available yet'))
|
||||
}
|
||||
|
||||
if (loading) return <div className="page"><p className="muted">Loading…</p></div>
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1>Devices</h1>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
{msg && <div className="ok-banner">{msg}</div>}
|
||||
|
||||
{canManage && (
|
||||
<>
|
||||
<div className="section-title">Discover</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 16 }}>
|
||||
{ALL_DEVICE_TYPES.map(dt => (
|
||||
<button
|
||||
key={dt}
|
||||
className="btn"
|
||||
disabled={discovering !== null}
|
||||
onClick={() => runDiscover(dt)}
|
||||
title={IMPLEMENTED_DEVICE_TYPES.includes(dt) ? '' : 'Not yet implemented — Phase 2/3'}
|
||||
>
|
||||
<RadioTower size={14} strokeWidth={1.75} />
|
||||
{discovering === dt ? 'Scanning…' : `Discover ${DEVICE_TYPE_LABELS[dt]}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="section-title">Mapped & Discovered Devices</div>
|
||||
{devices.length === 0 ? (
|
||||
<div className="empty-state">No devices discovered yet — run a discover scan above.</div>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table className="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Device</th>
|
||||
<th>Type</th>
|
||||
<th>Zone</th>
|
||||
<th>Location</th>
|
||||
<th>Health</th>
|
||||
<th>Photos</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{devices.map(d => (
|
||||
<Fragment key={d.id}>
|
||||
<tr className="clickable" onClick={() => toggleExpand(d.id)}>
|
||||
<td>{expanded === d.id ? <ChevronDown size={14} strokeWidth={1.75} /> : <ChevronRight size={14} strokeWidth={1.75} />}</td>
|
||||
<td>{d.discovered_name || d.external_ref}<div className="muted" style={{ fontSize: 11 }}>{d.external_ref}</div></td>
|
||||
<td>{DEVICE_TYPE_LABELS[d.device_type]}</td>
|
||||
<td onClick={e => e.stopPropagation()}>
|
||||
<select
|
||||
disabled={!canManage}
|
||||
value={d.zone_id ?? ''}
|
||||
onChange={e => assign(d.id, e.target.value ? Number(e.target.value) : null, d.location || '')}
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
{zones.map(z => <option key={z.id} value={z.id}>{z.name}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
<td onClick={e => e.stopPropagation()}>
|
||||
<input
|
||||
type="text" defaultValue={d.location || ''} placeholder="bedroom / bathroom…"
|
||||
disabled={!canManage}
|
||||
onBlur={e => assign(d.id, d.zone_id, e.target.value)}
|
||||
style={{ width: 120, border: '1px solid var(--card-border)', borderRadius: 6, padding: '4px 6px', fontSize: 12.5 }}
|
||||
/>
|
||||
</td>
|
||||
<td><span className={`badge badge-health-${d.health_state}`}>{d.health_state.replace('_', ' ')}</span></td>
|
||||
<td>{d.photo_count}</td>
|
||||
</tr>
|
||||
{expanded === d.id && (
|
||||
<tr>
|
||||
<td colSpan={7}>
|
||||
<div className="field-row">
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="field-hint" style={{ marginBottom: 4 }}>Device photo</div>
|
||||
<DevicePhotoUpload
|
||||
deviceId={d.id} photoType="device"
|
||||
photos={photosByDevice[d.id] || []}
|
||||
onChanged={() => reloadPhotos(d.id)}
|
||||
canDelete={canManage}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="field-hint" style={{ marginBottom: 4 }}>Serial / model plate photo</div>
|
||||
<DevicePhotoUpload
|
||||
deviceId={d.id} photoType="serial_plate"
|
||||
photos={photosByDevice[d.id] || []}
|
||||
onChanged={() => reloadPhotos(d.id)}
|
||||
canDelete={canManage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{canManage && d.zone_id && (
|
||||
<button className="btn btn-sm" onClick={() => createAsset(d.id)}>
|
||||
<Wrench size={13} strokeWidth={1.75} /> Create asset in Maintenance
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
213
frontend/src/pages/Settings.tsx
Normal file
213
frontend/src/pages/Settings.tsx
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Wifi, WifiOff } from 'lucide-react'
|
||||
import {
|
||||
fetchNewbookSites, saveExcludedSites, testNewbookConnection, syncZonesFromNewbook,
|
||||
fetchConfig, updateConfig, fetchMqttStatus, createZone,
|
||||
} from '../api'
|
||||
import type { NewbookSite, AppConfig } from '../types'
|
||||
|
||||
export default function Settings() {
|
||||
const [sites, setSites] = useState<NewbookSite[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [msg, setMsg] = useState('')
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
|
||||
const [config, setConfig] = useState<AppConfig | null>(null)
|
||||
const [pollMinutes, setPollMinutes] = useState(10)
|
||||
const [defaultArrival, setDefaultArrival] = useState('15:00:00')
|
||||
const [defaultDeparture, setDefaultDeparture] = useState('10:00:00')
|
||||
const [maintUrl, setMaintUrl] = useState('')
|
||||
const [maintKey, setMaintKey] = useState('')
|
||||
const [configSaving, setConfigSaving] = useState(false)
|
||||
|
||||
const [mqttConnected, setMqttConnected] = useState<boolean | null>(null)
|
||||
const [mqttNote, setMqttNote] = useState('')
|
||||
|
||||
const [publicAreaName, setPublicAreaName] = useState('')
|
||||
const [creatingZone, setCreatingZone] = useState(false)
|
||||
|
||||
function loadSites() {
|
||||
fetchNewbookSites()
|
||||
.then(res => setSites(res.sites))
|
||||
.catch(e => setError(e instanceof Error ? e.message : 'Failed to load NewBook sites'))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadSites()
|
||||
fetchConfig().then(cfg => {
|
||||
setConfig(cfg)
|
||||
setPollMinutes(cfg.poll_interval_minutes ?? 10)
|
||||
setDefaultArrival(cfg.default_arrival_time ?? '15:00:00')
|
||||
setDefaultDeparture(cfg.default_departure_time ?? '10:00:00')
|
||||
setMaintUrl(cfg.maintenance_url ?? '')
|
||||
setMaintKey(cfg.maintenance_api_key ?? '')
|
||||
}).catch(() => {})
|
||||
fetchMqttStatus().then(s => { setMqttConnected(s.connected); setMqttNote(s.note) }).catch(() => {})
|
||||
}, [])
|
||||
|
||||
function toggleExcluded(siteId: string) {
|
||||
setSites(sites.map(s => s.site_id === siteId ? { ...s, excluded: !s.excluded } : s))
|
||||
}
|
||||
|
||||
async function saveSites() {
|
||||
setError(''); setMsg('')
|
||||
try {
|
||||
await saveExcludedSites(sites.filter(s => s.excluded).map(s => s.site_id))
|
||||
setMsg('Saved'); setTimeout(() => setMsg(''), 2500)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Save failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
setTesting(true); setError(''); setMsg('')
|
||||
try {
|
||||
const res = await testNewbookConnection()
|
||||
setMsg(res.ok ? `Connection OK: ${res.message || ''}` : `Failed: ${res.error || 'unknown'}`)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Test failed')
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSync() {
|
||||
setSyncing(true); setError(''); setMsg('')
|
||||
try {
|
||||
const res = await syncZonesFromNewbook()
|
||||
setMsg(`Synced: ${res.created} created, ${res.updated} updated (${res.total} active sites)`)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Sync failed')
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveGeneralConfig() {
|
||||
setConfigSaving(true); setError(''); setMsg('')
|
||||
try {
|
||||
await Promise.all([
|
||||
updateConfig('poll_interval_minutes', pollMinutes),
|
||||
updateConfig('default_arrival_time', defaultArrival),
|
||||
updateConfig('default_departure_time', defaultDeparture),
|
||||
updateConfig('maintenance_url', maintUrl),
|
||||
updateConfig('maintenance_api_key', maintKey),
|
||||
])
|
||||
setMsg('Settings saved'); setTimeout(() => setMsg(''), 2500)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Save failed')
|
||||
} finally {
|
||||
setConfigSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreatePublicArea(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!publicAreaName.trim()) return
|
||||
setCreatingZone(true); setError(''); setMsg('')
|
||||
try {
|
||||
await createZone({ name: publicAreaName.trim(), zone_type: 'public_area' })
|
||||
setPublicAreaName('')
|
||||
setMsg('Public area zone created — assign devices to it on the Devices page.')
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Create failed')
|
||||
} finally {
|
||||
setCreatingZone(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="page"><p className="muted">Loading…</p></div>
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1>Settings</h1>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
{msg && <div className="ok-banner">{msg}</div>}
|
||||
|
||||
<div className="section-title">MQTT Broker</div>
|
||||
<div className="card" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{mqttConnected ? <Wifi size={16} strokeWidth={1.75} color="var(--health-healthy)" /> : <WifiOff size={16} strokeWidth={1.75} color="var(--danger)" />}
|
||||
<span>{mqttNote || 'Checking…'}</span>
|
||||
</div>
|
||||
|
||||
<div className="section-title">NewBook Rooms</div>
|
||||
<p className="field-hint" style={{ marginBottom: 10 }}>
|
||||
Untick rooms that shouldn't get NewBook-driven heating scheduling (e.g. owner-occupied or out-of-service rooms).
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 12 }}>
|
||||
{sites.map(s => (
|
||||
<label key={s.site_id} className="field-check" style={{
|
||||
padding: '8px 12px', border: '1px solid var(--card-border)', borderRadius: 8,
|
||||
background: s.excluded ? 'var(--body-bg)' : 'var(--card-bg)',
|
||||
}}>
|
||||
<input type="checkbox" checked={!s.excluded} onChange={() => toggleExcluded(s.site_id)} />
|
||||
<span style={{ flex: 1, textDecoration: s.excluded ? 'line-through' : 'none', color: s.excluded ? 'var(--text-mid)' : 'var(--text-dark)' }}>
|
||||
{s.site_name}
|
||||
</span>
|
||||
{s.category_name && <span className="muted" style={{ fontSize: 12 }}>{s.category_name}</span>}
|
||||
</label>
|
||||
))}
|
||||
{sites.length === 0 && <p className="muted">No sites returned — check NewBook connection.</p>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 24 }}>
|
||||
<button className="btn btn-primary" onClick={saveSites}>Save Visibility</button>
|
||||
<button className="btn" disabled={testing} onClick={handleTest}>{testing ? 'Testing…' : 'Test NewBook Connection'}</button>
|
||||
<button className="btn" disabled={syncing} onClick={handleSync}>{syncing ? 'Syncing…' : 'Sync Zones from NewBook'}</button>
|
||||
</div>
|
||||
|
||||
<div className="section-title">Add a Public Area</div>
|
||||
<form onSubmit={handleCreatePublicArea} style={{ display: 'flex', gap: 8, marginBottom: 24 }}>
|
||||
<input
|
||||
type="text" placeholder="e.g. Lobby, Restaurant, Bar"
|
||||
value={publicAreaName} onChange={e => setPublicAreaName(e.target.value)}
|
||||
style={{ flex: 1, border: '1px solid var(--card-border)', borderRadius: 8, padding: '8px 10px' }}
|
||||
/>
|
||||
<button className="btn btn-primary" disabled={creatingZone} type="submit">Add zone</button>
|
||||
</form>
|
||||
|
||||
<div className="section-title">Scheduling</div>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Poll interval (minutes)</label>
|
||||
<input type="number" min={1} value={pollMinutes} onChange={e => setPollMinutes(parseInt(e.target.value) || 10)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Default arrival time</label>
|
||||
<input type="text" value={defaultArrival} onChange={e => setDefaultArrival(e.target.value)} placeholder="15:00:00" />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Default departure time</label>
|
||||
<input type="text" value={defaultDeparture} onChange={e => setDefaultDeparture(e.target.value)} placeholder="10:00:00" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section-title">Maintenance Integration</div>
|
||||
<p className="field-hint" style={{ marginBottom: 10 }}>
|
||||
Not yet available — maintenance needs its own Settings → API Keys page first.
|
||||
Once that lands, generate a key there and paste it here to enable "Create asset in Maintenance" on the Devices page.
|
||||
</p>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Maintenance URL</label>
|
||||
<input type="text" value={maintUrl} onChange={e => setMaintUrl(e.target.value)} placeholder="https://hotel.example.com/maintenance" />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Maintenance API key</label>
|
||||
<input type="text" value={maintKey} onChange={e => setMaintKey(e.target.value)} placeholder="paste key" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<button className="btn btn-primary" disabled={configSaving} onClick={saveGeneralConfig}>
|
||||
{configSaving ? 'Saving…' : 'Save Settings'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue