Merge pull request #666 from mz8i/feature/545-dynamics

Feature 545: simple dynamics
This commit is contained in:
Maciej Ziarkowski 2021-03-11 19:37:20 +00:00 committed by GitHub
commit 653282f0f3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 755 additions and 56 deletions

View File

@ -507,4 +507,34 @@
<LineSymbolizer stroke="#888" stroke-width="3.0"/> <LineSymbolizer stroke="#888" stroke-width="3.0"/>
</Rule> </Rule>
</Style> </Style>
<Style name="dynamics_demolished_count">
<Rule>
<Filter>[demolished_buildings_count] &gt;= 7</Filter>
<PolygonSymbolizer fill="#bd0026" />
</Rule>
<Rule>
<Filter>[demolished_buildings_count] = 6</Filter>
<PolygonSymbolizer fill="#e31a1c" />
</Rule>
<Rule>
<Filter>[demolished_buildings_count] = 5</Filter>
<PolygonSymbolizer fill="#fc4e2a" />
</Rule>
<Rule>
<Filter>[demolished_buildings_count] = 4</Filter>
<PolygonSymbolizer fill="#fd8d3c" />
</Rule>
<Rule>
<Filter>[demolished_buildings_count] = 3</Filter>
<PolygonSymbolizer fill="#feb24c" />
</Rule>
<Rule>
<Filter>[demolished_buildings_count] = 2</Filter>
<PolygonSymbolizer fill="#fed976" />
</Rule>
<Rule>
<Filter>[demolished_buildings_count] = 1</Filter>
<PolygonSymbolizer fill="#ffe8a9" />
</Rule>
</Style>
</Map> </Map>

View File

@ -239,4 +239,10 @@ export const dataFieldsConfig = valueType<DataFieldConfig>()({ /* eslint-disable
verify: false, verify: false,
}, },
demolished_buildings: {
edit: true,
verify: false,
asJson: true,
sqlCast: 'jsonb',
},
}); });

View File

@ -4,4 +4,63 @@ import { dataFieldsConfig } from './dataFields';
export const fieldSchemaConfig: { [key in keyof typeof dataFieldsConfig]?: SomeJSONSchema} = { /*eslint-disable @typescript-eslint/camelcase */ export const fieldSchemaConfig: { [key in keyof typeof dataFieldsConfig]?: SomeJSONSchema} = { /*eslint-disable @typescript-eslint/camelcase */
demolished_buildings: {
type: 'array',
items: {
type: 'object',
required: ['year_constructed', 'year_demolished', 'overlap_present', 'links'],
properties: {
year_constructed: {
type: 'object',
required: ['min', 'max'],
additionalProperties: false,
properties: {
min: {
type: 'integer'
},
max: {
type: 'integer'
}
}
},
year_demolished: {
type: 'object',
required: ['min', 'max'],
additionalProperties: false,
properties: {
min: {
type: 'integer'
},
max: {
type: 'integer'
}
}
},
overlap_present: {
type: 'string',
enum: ['1%', '25%', '50%', '75%', '100%']
},
links: {
type: 'array',
items: {
type: 'string'
},
minItems: 1
}
},
additionalProperties: false,
}
} as JSONSchemaType<{
year_constructed: {
min: number;
max: number;
};
year_demolished: {
min: number;
max: number;
}
overlap_present: string;
links: string[];
}[]>,
} as const; } as const;

View File

@ -6,6 +6,9 @@ import { ArgumentError } from '../../errors/general';
import { updateLandUse } from './landUse'; import { updateLandUse } from './landUse';
/**
* Process land use classifications - derive land use order from land use groups
*/
async function processCurrentLandUseClassifications(buildingId: number, buildingUpdate: any): Promise<any> { async function processCurrentLandUseClassifications(buildingId: number, buildingUpdate: any): Promise<any> {
const currentBuildingData = await getBuildingData(buildingId); const currentBuildingData = await getBuildingData(buildingId);
@ -31,10 +34,26 @@ async function processCurrentLandUseClassifications(buildingId: number, building
} }
} }
/**
* Process Dynamics data - sort past buildings by construction date
*/
async function processDynamicsPastBuildings(buildingId: number, buildingUpdate: any): Promise<any> {
buildingUpdate.demolished_buildings = buildingUpdate.demolished_buildings.sort((a, b) => b.year_constructed.min - a.year_constructed.min);
return buildingUpdate;
}
/**
* Define any custom processing logic for specific building attributes
*/
export async function processBuildingUpdate(buildingId: number, buildingUpdate: any): Promise<any> { export async function processBuildingUpdate(buildingId: number, buildingUpdate: any): Promise<any> {
if(hasAnyOwnProperty(buildingUpdate, ['current_landuse_group'])) { if(hasAnyOwnProperty(buildingUpdate, ['current_landuse_group'])) {
buildingUpdate = await processCurrentLandUseClassifications(buildingId, buildingUpdate); buildingUpdate = await processCurrentLandUseClassifications(buildingId, buildingUpdate);
} }
if('demolished_buildings' in buildingUpdate) {
buildingUpdate = await processDynamicsPastBuildings(buildingId, buildingUpdate);
}
return buildingUpdate; return buildingUpdate;
} }

View File

@ -0,0 +1,11 @@
.field-row {
display: flex;
flex-direction: row;
justify-content: space-evenly;
justify-items: left;
}
/* make all row elements the same width */
.field-row * {
flex: 1 1 0px;
}

View File

@ -0,0 +1,11 @@
import React from 'react'
import './field-row.css';
export function FieldRow({children}) {
return (
<div className="field-row">
{children}
</div>
)
}

View File

@ -1,52 +0,0 @@
import React, { Fragment } from 'react';
import InfoBox from '../../components/info-box';
import DataEntry from '../data-components/data-entry';
import { DataEntryGroup } from '../data-components/data-entry-group';
import withCopyEdit from '../data-container';
import { CategoryViewProps } from './category-view-props';
/**
* Dynamics view/edit section
*/
const DynamicsView: React.FunctionComponent<CategoryViewProps> = (props) => (
<Fragment>
<InfoBox msg="This is what we're planning to collect in this section" />
<DataEntryGroup collapsed={false} name="Historical constructions and demolitions" showCount={false}>
<DataEntry
title="Current building"
slug=""
value=""
mode='view'
/>
<DataEntry
title="Past buildings"
slug=""
value=""
mode='view'
/>
</DataEntryGroup>
<DataEntry
title="Historical land use change"
slug=""
value=""
mode='view'
/>
<DataEntry
title="Longitudinal historical footprints (raster) link"
slug=""
value=""
mode='view'
/>
<DataEntry
title="Longitudinal historical footprints (vector) link"
slug=""
value=""
mode='view'
/>
</Fragment>
);
const DynamicsContainer = withCopyEdit(DynamicsView);
export default DynamicsContainer;

View File

@ -0,0 +1,36 @@
.dynamics-building-pane {
position: relative;
background-color: #f1f1f1;
border: 1px solid #cccccc;
padding: 10px;
border-radius: 5px;
margin-top: 10px;
margin-bottom: 15px;
}
.dynamics-building-pane.new-record {
background-color: #f6f6f6;
border: 1px dashed #cccccc;
}
.h6 {
margin-top: 40px;
}
.delete-record-button {
position: absolute;
top:10px;
right: 10px;
}
.add-record-button {
margin-top: 20px;
}
.field-row * {
margin-right: 1px;
}
.lifespan-entry {
flex: 0 1 27%;
}

View File

@ -0,0 +1,283 @@
import React, { useCallback, useState } from 'react'
import * as _ from 'lodash';
import { BuildingAttributes } from '../../../models/building';
import { FieldRow } from '../../data-components/field-row';
import DataEntry, { BaseDataEntryProps } from '../../data-components/data-entry';
import { dataFields } from '../../../config/data-fields-config';
import SelectDataEntry from '../../data-components/select-data-entry';
import MultiDataEntry from '../../data-components/multi-data-entry/multi-data-entry';
import { NumberRangeDataEntry } from './number-range-data-entry';
import './dynamics-data-entry.css';
type DemolishedBuilding = (BuildingAttributes['demolished_buildings'][number]);
export const DynamicsBuildingPane: React.FC<{className?: string}> = ({children, className}) => (
<div className={`dynamics-building-pane ${className ?? ''}`} >
{children}
</div>
);
function lifespan(a: number, b: number): number {
if(a == undefined || b == undefined) return undefined;
const diff = a - b;
return Math.max(diff, 0);
}
function formatRange(minSpan: number, maxSpan: number): string {
if(minSpan == undefined || maxSpan == undefined) return '';
if(minSpan === maxSpan) return minSpan + '';
return `${minSpan}-${maxSpan}`;
}
interface DynamicsDataRowProps {
value: DemolishedBuilding;
onChange?: (value: DemolishedBuilding) => void;
disabled?: boolean;
maxYear?: number;
minYear?: number;
mode?: 'view' | 'edit' | 'multi-edit';
required?: boolean;
validateForm?: boolean;
index?: number;
}
const DynamicsDataRow: React.FC<DynamicsDataRowProps> = ({
value = {} as DemolishedBuilding,
onChange,
disabled = false,
maxYear,
minYear,
mode,
required = false,
validateForm = false,
index
}) => {
const onFieldChange = useCallback((key: string, val: any) => {
const changedValue = {...value};
changedValue[key] = val;
onChange(changedValue);
}, [value, onChange]);
const maxLifespan = lifespan(value.year_demolished?.max, value.year_constructed?.min);
const minLifespan = lifespan(value.year_demolished?.min, value.year_constructed?.max);
return (
<>
<FieldRow>
<div>
<NumberRangeDataEntry
slug='year_constructed'
slugModifier={index}
title={dataFields.demolished_buildings.items.year_constructed.title}
onChange={onFieldChange}
value={value.year_constructed}
disabled={disabled}
max={value.year_demolished?.min ?? maxYear}
min={minYear}
placeholderMin='Earliest'
placeholderMax='Latest'
titleMin={`${dataFields.demolished_buildings.items.year_constructed.title}: earliest estimate`}
titleMax={`${dataFields.demolished_buildings.items.year_constructed.title}: latest estimate`}
required={required}
/>
</div>
<div>
<NumberRangeDataEntry
slug='year_demolished'
slugModifier={index}
title={dataFields.demolished_buildings.items.year_demolished.title}
onChange={onFieldChange}
value={value.year_demolished}
disabled={disabled}
max={maxYear}
min={value.year_constructed?.max ?? minYear}
placeholderMin='Earliest'
placeholderMax='Latest'
titleMin={`${dataFields.demolished_buildings.items.year_demolished.title}: earliest estimate`}
titleMax={`${dataFields.demolished_buildings.items.year_demolished.title}: latest estimate`}
required={required}
/>
</div>
<div className='lifespan-entry'>
<DataEntry
slug='lifespan'
slugModifier={index}
title={dataFields.demolished_buildings.items.lifespan.title}
value={formatRange(minLifespan, maxLifespan)}
disabled={true}
/>
</div>
</FieldRow>
<SelectDataEntry
slug='overlap_present'
slugModifier={index}
title={dataFields.demolished_buildings.items.overlap_present.title}
onChange={onFieldChange}
value={value.overlap_present}
options={[
{value: '1%', label: '1% - almost no overlap with current site'},
'25%',
'50%',
'75%',
{value: '100%', label: '100% - fully contained in current site'}
]}
disabled={disabled}
required={required}
/>
<MultiDataEntry
slug='links'
slugModifier={index}
title={dataFields.demolished_buildings.items.links.title}
onChange={onFieldChange}
value={value.links}
disabled={disabled}
editableEntries={true}
mode={mode}
required={required}
/>
</>
)
};
interface DynamicsDataEntryProps extends BaseDataEntryProps {
value: DemolishedBuilding[];
editableEntries: boolean;
maxYear: number;
minYear: number;
onSaveAdd: (slug: string, newItem: any) => void;
hasEdits: boolean;
}
function isValid(val: DemolishedBuilding) {
if(val == undefined) return false;
if(typeof val.year_constructed?.min !== 'number') return false;
if(typeof val.year_constructed?.max !== 'number') return false;
if(typeof val.year_demolished?.min !== 'number') return false;
if(typeof val.year_demolished?.max !== 'number') return false;
if(val.overlap_present == undefined) return false;
if(val.links == undefined || val.links.length < 1) return false;
return true;
}
export const DynamicsDataEntry: React.FC<DynamicsDataEntryProps> = (props) => {
const [newValue, setNewValue] = useState<DemolishedBuilding>();
const values: DemolishedBuilding[] = props.value ?? [];
const isEditing = props.mode === 'edit';
const isDisabled = !isEditing || props.disabled;
const isEdited = !_.isEmpty(newValue);
const valid = isValid(newValue);
const addNew = useCallback(() => {
const val = {...newValue};
setNewValue(undefined);
props.onSaveAdd(props.slug, val);
}, [values, newValue]);
const edit = useCallback((id: number, val: DemolishedBuilding) => {
const editedValues = [...values];
editedValues.splice(id, 1, val);
props.onChange(props.slug, editedValues);
}, [values]);
const remove = useCallback((id: number) => {
const editedValues = [...values];
editedValues.splice(id, 1);
props.onChange(props.slug, editedValues);
}, [values]);
return (
<>
<div>
{
isEditing &&
<>
<h6 className="h6">Existing records for demolished buildings</h6>
<label>Please supply sources for any edits of existing records</label>
</>
}
<ul className="data-link-list">
{
values.length === 0 &&
<div className="input-group">
<input className="form-control no-entries" type="text" value="No records so far" disabled={true} />
</div>
}
{
values.map((pastBuilding, id) => (
<li key={id}>
<DynamicsBuildingPane>
<label>Demolished building</label>
{
!isDisabled &&
<button type="button" className="btn btn-outline-dark delete-record-button"
title="Delete Record"
onClick={() => remove(id)}
data-index={id}
>x</button>
}
<DynamicsDataRow
value={pastBuilding}
disabled={!props.editableEntries || isDisabled}
onChange={(value) => edit(id, value)}
minYear={props.minYear}
maxYear={props.maxYear}
mode={props.mode}
required={true}
index={id}
/>
</DynamicsBuildingPane>
</li>
))
}
</ul>
{
!isDisabled &&
<div className='new-record-section'>
<h6 className="h6">Add a new demolished building record</h6>
<DynamicsBuildingPane className='new-record'>
<DynamicsDataRow
value={newValue}
onChange={setNewValue}
disabled={isDisabled}
minYear={props.minYear}
maxYear={props.maxYear}
mode={props.mode}
/>
<label>Please save all your edits before navigating away from the currently selected building - these will be erased otherwise.</label>
<button type="button"
className="btn btn-primary btn-block add-record-button"
title="Add to list"
onClick={addNew}
disabled={!valid || props.hasEdits}
>
{
props.hasEdits ?
'Save or discard edits first to add a new record' :
(isEdited && !valid) ?
'Fill in all fields to save record' :
'Save new record'
}
</button>
</DynamicsBuildingPane>
</div>
}
</div>
</>
);
};

View File

@ -0,0 +1,123 @@
import React from 'react';
import { Link } from 'react-router-dom';
import InfoBox from '../../../components/info-box';
import { Category } from '../../../config/categories-config';
import { dataFields } from '../../../config/data-fields-config';
import DataEntry from '../../data-components/data-entry';
import { DataEntryGroup } from '../../data-components/data-entry-group';
import { DynamicsBuildingPane, DynamicsDataEntry } from './dynamics-data-entry';
import { FieldRow } from '../../data-components/field-row';
import NumericDataEntry from '../../data-components/numeric-data-entry';
import withCopyEdit from '../../data-container';
import { CategoryViewProps } from '../category-view-props';
/**
* Dynamics view/edit section
*/
const DynamicsView: React.FunctionComponent<CategoryViewProps> = (props) => {
const building = props.building;
const thisYear = (new Date()).getFullYear();
const currentBuildingConstructionYear = building.date_year || undefined;
const ageLinkUrl = `/${props.mode}/${Category.Age}/${props.building.building_id}`;
return (<>
<DataEntryGroup collapsed={false} name="Constructions and demolitions on this site" showCount={false}>
<DynamicsBuildingPane>
<label>Current building (age data <Link to={ageLinkUrl}>editable here</Link>)</label>
<FieldRow>
<div>
<NumericDataEntry
slug=''
title={dataFields.demolished_buildings.items.year_constructed.title}
value={currentBuildingConstructionYear}
disabled={true}
mode='view'
/>
</div>
<div>
<NumericDataEntry
slug=''
title={dataFields.demolished_buildings.items.year_demolished.title}
value={undefined}
placeholder='---'
disabled={true}
mode='view'
/>
</div>
<div style={{flex: '0 1 27%'}}>
<DataEntry
slug=''
title='Lifespan to date'
value={ (thisYear - currentBuildingConstructionYear) + ''}
disabled={true}
mode='view'
/>
</div>
</FieldRow>
</DynamicsBuildingPane>
{
currentBuildingConstructionYear != undefined ?
<>
<DynamicsDataEntry
/*
Will clear the edits and new record data upon navigating to another building.
Should get a better way to do this, plus a way to actually keep unsaved edits.
*/
key={building.building_id}
value={building.demolished_buildings}
editableEntries={true}
slug='demolished_buildings'
title={undefined}
mode={props.mode}
onChange={props.onChange}
onSaveAdd={props.onSaveAdd}
hasEdits={props.edited}
maxYear={currentBuildingConstructionYear}
minYear={50}
/>
{
props.mode === 'view' &&
<InfoBox>Switch to edit mode to add/edit past building records</InfoBox>
}
</> :
<InfoBox>To add historical records, fill in the <Link to={ageLinkUrl}>Age</Link> data first.</InfoBox>
}
</DataEntryGroup>
<DataEntryGroup name="Future planned data collection" collapsed={false} showCount={false}>
<DataEntry
title="Historical land use change"
slug=""
value=""
mode='view'
/>
<DataEntry
title="Longitudinal historical footprints (raster) link"
slug=""
value=""
mode='view'
/>
<DataEntry
title="Longitudinal historical footprints (vector) link"
slug=""
value=""
mode='view'
/>
</DataEntryGroup>
<InfoBox>
This section is under development in collaboration with the historic environment sector.
Please let us know your suggestions on the <a href="https://discuss.colouring.london">discussion forum</a>! (external link - save your edits first)
</InfoBox>
</>)
};
const DynamicsContainer = withCopyEdit(DynamicsView);
export default DynamicsContainer;

View File

@ -0,0 +1,7 @@
.min-number-input {
border-right: none;
}
.max-number-input {
border-left: 1px dashed #ccc;
}

View File

@ -0,0 +1,98 @@
import React, { useCallback, useState } from 'react';
import { BaseDataEntryProps } from '../../data-components/data-entry';
import { DataTitleCopyable } from '../../data-components/data-title';
import { FieldRow } from '../../data-components/field-row';
import './number-range-data-entry.css';
interface NumberRangeDataEntryProps extends BaseDataEntryProps {
value?: {
min: number;
max: number;
};
placeholderMin?: string;
placeholderMax?: string;
titleMin?: string;
titleMax?: string;
step?: number;
min?: number;
max?: number;
}
export const NumberRangeDataEntry: React.FC<NumberRangeDataEntryProps> = (props) => {
const {min: minValue, max: maxValue} = props.value ?? {};
const [minEdited, setMinEdited] = useState(minValue != undefined);
const [maxEdited, setMaxEdited] = useState(maxValue != undefined);
const isDisabled = props.mode === 'view' || props.disabled;
const slugWithModifier = props.slug + (props.slugModifier ?? '');
return (
<div>
<DataTitleCopyable
slug={props.slug}
slugModifier={props.slugModifier}
title={props.title}
tooltip={props.tooltip}
disabled={props.disabled || props.value == undefined}
copy={props.copy}
/>
<FieldRow>
<input
className="form-control min-number-input"
type="number"
id={slugWithModifier}
name={slugWithModifier}
value={minValue ?? ''}
step={props.step ?? 1}
max={(maxEdited ? maxValue : null) ?? props.max}
min={props.min}
disabled={isDisabled}
placeholder={props.placeholderMin}
title={props.titleMin}
required={props.required}
onChange={e => {
const val = e.target.value === '' ? null : parseFloat(e.target.value);
setMinEdited(val != undefined);
props.onChange(
props.slug,
{
min: val,
max: maxEdited ? maxValue : val
}
);
}
}
/>
<input
className="form-control max-number-input"
type="number"
id={`${props.slug}-max`}
name={`${props.slug}-max`}
value={maxValue ?? ''}
step={props.step ?? 1}
max={props.max}
min={(minEdited ? minValue : null) ?? props.min}
disabled={isDisabled}
placeholder={props.placeholderMax}
title={props.titleMax}
required={props.required}
onChange={e => {
const val = e.target.value === '' ? null : parseFloat(e.target.value);
setMaxEdited(val != undefined);
props.onChange(
props.slug,
{
min: minEdited ? minValue : val,
max: val
}
);
}
}
/>
</FieldRow>
</div>
);
};

View File

@ -115,7 +115,6 @@ export const categoriesConfig: {[key in Category]: CategoryDefinition} = {
intro: "What's the building's context? Coming soon…", intro: "What's the building's context? Coming soon…",
}, },
[Category.Dynamics]: { [Category.Dynamics]: {
inactive: true,
slug: 'dynamics', slug: 'dynamics',
name: 'Dynamics', name: 'Dynamics',
aboutUrl: 'https://pages.colouring.london/dynamics', aboutUrl: 'https://pages.colouring.london/dynamics',

View File

@ -185,10 +185,34 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
}, },
}, },
[Category.Dynamics]: { [Category.Dynamics]: {
mapStyle: undefined, mapStyle: 'dynamics_demolished_count',
legend: { legend: {
title: 'Dynamics', title: 'Dynamics',
elements: [] description: 'Past (demolished) buildings on site',
elements: [
{
text: '7+',
color: '#bd0026',
}, {
text: '6',
color: '#e31a1c',
}, {
text: '5',
color: '#fc4e2a',
}, {
text: '4',
color: '#fd8d3c',
}, {
text: '3',
color: '#feb24c',
}, {
text: '2',
color: '#fed976',
}, {
text: '1',
color: '#ffe8a9',
}
],
}, },
} }

View File

@ -3,7 +3,7 @@ import { Category } from './categories-config';
import AgeContainer from '../building/data-containers/age'; import AgeContainer from '../building/data-containers/age';
import CommunityContainer from '../building/data-containers/community'; import CommunityContainer from '../building/data-containers/community';
import ConstructionContainer from '../building/data-containers/construction'; import ConstructionContainer from '../building/data-containers/construction';
import DynamicsContainer from '../building/data-containers/dynamics'; import DynamicsContainer from '../building/data-containers/dynamics/dynamics';
import LocationContainer from '../building/data-containers/location'; import LocationContainer from '../building/data-containers/location';
import PlanningContainer from '../building/data-containers/planning'; import PlanningContainer from '../building/data-containers/planning';
import SizeContainer from '../building/data-containers/size'; import SizeContainer from '../building/data-containers/size';

View File

@ -411,4 +411,38 @@ export const dataFields = { /* eslint-disable @typescript-eslint/camelcase */
title: "Total number of likes", title: "Total number of likes",
example: 100, example: 100,
}, },
demolished_buildings: {
category: Category.Dynamics,
title: 'Past (demolished) buildings on this site',
items: {
year_constructed: {
title: 'Construction year',
example: { min: 1989, max: 1991 },
},
year_demolished: {
title: 'Demolition year',
example: { min: 1993, max: 1994 },
},
lifespan: {
title: 'Lifespan',
example: "2-5",
},
overlap_present: {
title: 'Roughly what percentage of this building was inside the current site boundary?',
tooltip: '',
example: "25%"
},
links: {
title: 'Links / sources',
example: ["", ""]
}
},
example: [
{
year_constructed: { min: 1989, max: 1991 },
year_demolished: { min: 1993, max: 1994 },
lifespan: "2-5", overlap_present: "50%", links: ["", ""]}
]
}
}; };

View File

@ -133,6 +133,13 @@ const LAYER_QUERIES = {
buildings buildings
WHERE WHERE
current_landuse_order IS NOT NULL`, current_landuse_order IS NOT NULL`,
dynamics_demolished_count: `
SELECT
geometry_id,
jsonb_array_length(demolished_buildings) as demolished_buildings_count
FROM
buildings
WHERE jsonb_array_length(demolished_buildings) > 0`,
}; };
const GEOMETRY_FIELD = 'geometry_geom'; const GEOMETRY_FIELD = 'geometry_geom';

View File

@ -0,0 +1,2 @@
ALTER TABLE buildings
DROP demolished_buildings;

View File

@ -0,0 +1,2 @@
ALTER TABLE buildings
ADD column demolished_buildings JSONB DEFAULT '[]'::JSONB;