From 342e4859020289f14fd4c3b9a7e08ae72eb98487 Mon Sep 17 00:00:00 2001 From: Frederik Leonhardt Date: Wed, 1 May 2024 23:51:08 +1200 Subject: [PATCH 1/6] Group accordions and track accordion state --- src/models/state.ts | 5 +++++ src/views/child.ts | 8 +++++++- test/mock.ts | 2 ++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/models/state.ts b/src/models/state.ts index 4315be6..9a1b02d 100644 --- a/src/models/state.ts +++ b/src/models/state.ts @@ -28,6 +28,9 @@ interface IAppActions { const AppActions = (app: App): IAppActions => ({ addChild: (child: Child = ChildState()) => { + if (child.open) { + app.children.forEach(c => (c.open = false)); + } app.children.push(child); }, removeChild: (idx: number) => { @@ -64,6 +67,7 @@ interface Child { name: string | null; dateOfBirth?: LocalDate; sex: Sex | null; + open: boolean; colourHex?: string; age?: Period; // computed measurements: Measurement[]; @@ -83,6 +87,7 @@ interface IChildActions { const ChildState = (): Child => ({ idx: 0, + open: true, name: null, dateOfBirth: undefined, sex: null, diff --git a/src/views/child.ts b/src/views/child.ts index 93fb1d7..747f46e 100644 --- a/src/views/child.ts +++ b/src/views/child.ts @@ -61,7 +61,13 @@ const ChildComponent: m.Component> = { return m( 'details', - {open: 'open'}, + { + open: state.open, + name: 'children', + ontoggle: (e: ToggleEvent) => { + state.open = e.newState === 'open'; + }, + }, m( 'summary', `Child ${state.idx + 1}: ${name} ${age}`, diff --git a/test/mock.ts b/test/mock.ts index a4b5e81..5f9f3c3 100644 --- a/test/mock.ts +++ b/test/mock.ts @@ -15,6 +15,7 @@ measurement.dateOfBirth = LocalDate.of(2020, 3, 23); const child0: Child = { idx: 0, + open: true, name: 'Ava', dateOfBirth: measurement.dateOfBirth, sex: 'female', @@ -41,6 +42,7 @@ measurement.dateOfBirth = LocalDate.of(2022, 2, 10); const child1: Child = { idx: 1, + open: false, name: 'William', dateOfBirth: measurement.dateOfBirth, sex: 'male', From ea0ce7c195d5f311472a643d67b77f1311e3f653 Mon Sep 17 00:00:00 2001 From: Frederik Leonhardt Date: Wed, 1 May 2024 23:52:50 +1200 Subject: [PATCH 2/6] Automatically save application state in local storage --- src/models/export.ts | 8 ++++++-- src/views/app.ts | 18 +++++++++++++++--- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/models/export.ts b/src/models/export.ts index 5b5bf80..78560e9 100644 --- a/src/models/export.ts +++ b/src/models/export.ts @@ -22,7 +22,11 @@ const reviver = (key: string, value: any): any => { }; function exportState(state: T): string { - const serialisedState = JSON.stringify(state); + return JSON.stringify(state); +} + +function exportStateBase64Url(state: T): string { + const serialisedState = exportState(state); const encodedState = b64EncodeUnicode(serialisedState); return `data:application/json;base64,${encodedState}`; } @@ -31,4 +35,4 @@ function importState(state: string): T { return JSON.parse(state, reviver); } -export {exportState, importState}; +export {exportState, exportStateBase64Url, importState}; diff --git a/src/views/app.ts b/src/views/app.ts index 915cfe1..78f2543 100644 --- a/src/views/app.ts +++ b/src/views/app.ts @@ -15,7 +15,7 @@ import { } from '../models/state'; import {Series, SeriesObject} from 'chartist'; import {ChronoUnit, LocalDate, Period} from '@js-joda/core'; -import {exportState, importState} from '../models/export'; +import {exportState, exportStateBase64Url, importState} from '../models/export'; import {dateHistogram, dateHistogramAggregation} from '../models/timeseries'; function bucketInto( @@ -76,9 +76,21 @@ function bucketInto( return normalised; } +const LOCAL_STORAGE_KEY = 'growth-data'; + const AppComponent: m.Component> = { - oninit({attrs: {state}}) { - // pass + oninit({attrs: {actions}}) { + // load state from local storage + const data = localStorage.getItem(LOCAL_STORAGE_KEY); + if (data !== null) { + const state: Child[] = importState(data); + actions.import(state); + } + }, + + onupdate({attrs: {state}}) { + // save state into local storage + localStorage.setItem(LOCAL_STORAGE_KEY, exportState(state.children)); }, view({attrs: {state, actions}}) { From 82aaaddd4d87e94bd5e4bae986e9f5bfba7b7f6f Mon Sep 17 00:00:00 2001 From: Frederik Leonhardt Date: Wed, 1 May 2024 23:55:10 +1200 Subject: [PATCH 3/6] Split data import/export into dedicated component --- src/views/app.ts | 73 ++++++++++++++++++++++++++++++++---------------- 1 file changed, 49 insertions(+), 24 deletions(-) diff --git a/src/views/app.ts b/src/views/app.ts index 78f2543..f710874 100644 --- a/src/views/app.ts +++ b/src/views/app.ts @@ -155,30 +155,6 @@ const AppComponent: m.Component> = { {id: 'export', href: stateUrl, download: 'growth-data.json'}, '💾 Download' ) - ), - m( - 'li', - m('label', {for: 'import', class: 'main'}, 'Import data'), - m('input', { - type: 'file', - id: 'import', - accept: 'application/json', - onchange: (e: Event) => { - const name = (e.currentTarget as HTMLInputElement).value; - const file = (e.currentTarget as HTMLInputElement).files?.[0]; - const reader = new FileReader(); - reader.onload = () => { - const state: Child[] = importState(reader.result as string); - actions.import(state); - // force redraw as this event is not managed by mithril - m.redraw(); - }; - if (file) { - reader.readAsText(file); - (e.currentTarget as HTMLInputElement).value = ''; - } - }, - }) ) ) ), @@ -199,8 +175,57 @@ const AppComponent: m.Component> = { actions: ChartActions(state.chart), }), m(ChartComponent, state.chart), + m('h2', 'Your Data'), + m(DataManagementComponent, {state, actions}), ]; }, }; +const DataManagementComponent: m.Component> = { + view({attrs: {state, actions}}) { + const stateUrl = exportStateBase64Url(state.children); + + return m( + 'fieldset', + m('legend', 'Data management'), + m( + 'ul', + m( + 'li', + m('label', {for: 'export', class: 'main'}, 'Export data'), + m( + 'a', + {id: 'export', href: stateUrl, download: 'growth-data.json'}, + '💾 Download' + ) + ), + m( + 'li', + m('label', {for: 'import', class: 'main'}, 'Import data'), + m('input', { + type: 'file', + id: 'import', + accept: 'application/json', + onchange: (e: Event) => { + const name = (e.currentTarget as HTMLInputElement).value; + const file = (e.currentTarget as HTMLInputElement).files?.[0]; + const reader = new FileReader(); + reader.onload = () => { + const state: Child[] = importState(reader.result as string); + actions.import(state); + // force redraw as this event is not managed by mithril + m.redraw(); + }; + if (file) { + reader.readAsText(file); + (e.currentTarget as HTMLInputElement).value = ''; + } + }, + }) + ) + ) + ); + }, +}; + export default AppComponent; From e0863f06c3d8cb99724f830572c15b9fdf41903b Mon Sep 17 00:00:00 2001 From: Frederik Leonhardt Date: Wed, 1 May 2024 23:58:40 +1200 Subject: [PATCH 4/6] Move constants into separate file --- src/models/constants.ts | 19 +++++++++++++++++++ src/models/state.ts | 20 +------------------- src/views/app.ts | 4 +--- 3 files changed, 21 insertions(+), 22 deletions(-) create mode 100644 src/models/constants.ts diff --git a/src/models/constants.ts b/src/models/constants.ts new file mode 100644 index 0000000..b08ae4e --- /dev/null +++ b/src/models/constants.ts @@ -0,0 +1,19 @@ +export const LOCAL_STORAGE_KEY = 'growth-data'; + +// see chart.scss +export const COLOURS = [ + '#0544d3', + '#d17905', + '#59922b', + '#d70206', + '#6b0392', + '#f4c63d', + '#453d3f', + '#e6805e', + '#dda458', + '#eacf7d', + '#86797d', + '#b2c326', + '#6188e2', + '#a748ca', +]; diff --git a/src/models/state.ts b/src/models/state.ts index 9a1b02d..3b53d8c 100644 --- a/src/models/state.ts +++ b/src/models/state.ts @@ -1,6 +1,7 @@ import {LocalDate, Period} from '@js-joda/core'; import charts, {ChartConfig} from '../data/who'; import {SeriesObject} from 'chartist'; +import {COLOURS} from './constants'; // State and actions definitions type MitosisAttr = { @@ -43,24 +44,6 @@ const AppActions = (app: App): IAppActions => ({ type Sex = 'female' | 'male'; -// see chart.scss -const COLOURS = [ - '#0544d3', - '#d17905', - '#59922b', - '#d70206', - '#6b0392', - '#f4c63d', - '#453d3f', - '#e6805e', - '#dda458', - '#eacf7d', - '#86797d', - '#b2c326', - '#6188e2', - '#a748ca', -]; - // Child interface Child { idx: number; @@ -221,5 +204,4 @@ export { ChartState, IChartActions, ChartActions, - COLOURS, }; diff --git a/src/views/app.ts b/src/views/app.ts index f710874..ccb05ae 100644 --- a/src/views/app.ts +++ b/src/views/app.ts @@ -5,7 +5,6 @@ import {ChartComponent, ChartSelectorComponent} from './chart'; import ChildComponent from './child'; import { App, - COLOURS, ChartActions, Child, ChildActions, @@ -17,6 +16,7 @@ import {Series, SeriesObject} from 'chartist'; import {ChronoUnit, LocalDate, Period} from '@js-joda/core'; import {exportState, exportStateBase64Url, importState} from '../models/export'; import {dateHistogram, dateHistogramAggregation} from '../models/timeseries'; +import {COLOURS, LOCAL_STORAGE_KEY} from '../models/constants'; function bucketInto( origin: LocalDate, @@ -76,8 +76,6 @@ function bucketInto( return normalised; } -const LOCAL_STORAGE_KEY = 'growth-data'; - const AppComponent: m.Component> = { oninit({attrs: {actions}}) { // load state from local storage From a411a3d7714467826926acd2e5736d5861fa9f04 Mon Sep 17 00:00:00 2001 From: Frederik Leonhardt Date: Thu, 2 May 2024 00:03:26 +1200 Subject: [PATCH 5/6] Update header design - Rewrite header as nested flex layout - Display tagline in header - Update HTML heading styles --- src/models/constants.ts | 18 ++++++++ src/models/state.ts | 8 +++- src/styles/index.scss | 95 +++++++++++++++++++++++++++++++---------- src/views/app.ts | 30 +++++-------- 4 files changed, 108 insertions(+), 43 deletions(-) diff --git a/src/models/constants.ts b/src/models/constants.ts index b08ae4e..b699a79 100644 --- a/src/models/constants.ts +++ b/src/models/constants.ts @@ -1,5 +1,23 @@ export const LOCAL_STORAGE_KEY = 'growth-data'; +export const TAGLINES = [ + { + quote: 'Because paper charts are hard.', + author: 'Millenial', + source: 'Tales of the Digital Age', + }, + { + quote: "Who's got the time and energy to find the damn paper?", + author: 'A new parent', + source: 'Children of Fury', + }, + { + quote: 'My toddler destroyed the original!', + author: 'Anonymous parent', + source: 'Chronicles of the Twisted Paper', + }, +]; + // see chart.scss export const COLOURS = [ '#0544d3', diff --git a/src/models/state.ts b/src/models/state.ts index 3b53d8c..99d285d 100644 --- a/src/models/state.ts +++ b/src/models/state.ts @@ -1,7 +1,7 @@ import {LocalDate, Period} from '@js-joda/core'; import charts, {ChartConfig} from '../data/who'; import {SeriesObject} from 'chartist'; -import {COLOURS} from './constants'; +import {COLOURS, TAGLINES} from './constants'; // State and actions definitions type MitosisAttr = { @@ -11,11 +11,17 @@ type MitosisAttr = { // Root interface App { + tagline: { + quote: string; + author: string; + source: string; + }; children: Child[]; chart: Chart; } const AppState = (): App => ({ + tagline: TAGLINES[Math.floor(Math.random() * TAGLINES.length)], children: [ChildState()], chart: ChartState(), }); diff --git a/src/styles/index.scss b/src/styles/index.scss index cee623a..94cf232 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -1,42 +1,92 @@ @import url('https://fonts.googleapis.com/css2?family=Jersey+15&display=swap'); body { - min-width: 300px; - background-image: linear-gradient( - to bottom, - #fae0b4 200px, - white 250px, - white 100% - ); + min-width: 350px; + + background-image: linear-gradient(to bottom, + #fae0b4 200px, + white 275px, + white 100%); +} + +h2 { + font-family: 'Jersey 15', sans-serif; + font-weight: 400; + font-size: 3em; + text-transform: uppercase; + + &::before { + content: '> '; + } + + &::after { + content: ' <'; + } +} + +header { + // flex row layout + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + gap: 20px; } .logo { background-clip: content-box; - - background-color: #fcfcfc; background-image: url('../assets/logo.png'); background-position: left; background-repeat: no-repeat; background-size: contain; - border-style: solid; border-radius: 40px; + border-width: 8px; + border-top-style: dashed; + border-top-width: 3px; + border-bottom-style: dashed; + border-bottom-width: 3px; + border-right-style: solid; + border-left-style: solid; + + margin: 5px 5px; + + width: 200px; + min-width: 200px; + height: 200px; + &:hover { + transform: scale(-1, 1); + } +} + +.title-container { + // flex column layout display: flex; - justify-content: center; - align-items: center; + flex-direction: column; +} - height: 200px; +.title { + font-family: 'Jersey 15', sans-serif; + font-weight: 400; + font-style: bold; + font-size: calc(2em + 2 * (100vw - 120px) / 100); - .title { - margin-left: 200px; + text-transform: uppercase; + text-align: center; +} + +.tagline { + text-align: center; +} - font-family: 'Jersey 15', sans-serif; - font-weight: 400; - font-style: bold; - font-size: calc(2em + 2 * (100vw - 120px) / 100); +blockquote { + p::before { + content: '\201C'; + } - text-transform: uppercase; + p::after { + content: '\201D'; } } @@ -104,7 +154,7 @@ input:invalid { padding-left: 0.5em; } -input:not(:placeholder-shown):invalid + .error { +input:not(:placeholder-shown):invalid+.error { display: inline-block; } @@ -141,6 +191,7 @@ caption { // Small screens @media only screen and (max-width: 820px) { + // Force table to not display like tables anymore table, thead, @@ -182,4 +233,4 @@ caption { font-weight: bold; content: attr(data-label); } -} +} \ No newline at end of file diff --git a/src/views/app.ts b/src/views/app.ts index ccb05ae..00a40b5 100644 --- a/src/views/app.ts +++ b/src/views/app.ts @@ -125,33 +125,23 @@ const AppComponent: m.Component> = { state.chart.data = childData; } - const stateUrl = exportState(state.children); + const {quote, author, source} = state.tagline; return [ m( 'header', + m('.logo', { + alt: 'Baby on weighing scales as pixel art', + }), m( - '.logo', - { - alt: 'Baby on weighing scales', - }, - m('.title', 'Child Growth Charts') - ) - ), - m('h2', 'Summary'), - m('p', 'Because paper charts are hard.'), - m( - 'fieldset', - m('legend', 'Data management'), - m( - 'ul', + '.title-container', + m('.title', 'Child Growth Charts'), m( - 'li', - m('label', {for: 'export', class: 'main'}, 'Export data'), + '.tagline', m( - 'a', - {id: 'export', href: stateUrl, download: 'growth-data.json'}, - '💾 Download' + 'blockquote', + m('p', quote), + m('footer', `—${author}, `, m('cite', source)) ) ) ) From 172487d75f654108953e64b6fb9eae58d8858983 Mon Sep 17 00:00:00 2001 From: Frederik Leonhardt Date: Thu, 2 May 2024 00:07:04 +1200 Subject: [PATCH 6/6] Add anchors to HTML headings --- src/views/app.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/views/app.ts b/src/views/app.ts index 00a40b5..1206240 100644 --- a/src/views/app.ts +++ b/src/views/app.ts @@ -146,7 +146,7 @@ const AppComponent: m.Component> = { ) ) ), - m('h2', 'Children'), + m('h2#children', 'Children'), children, m( 'button', @@ -157,13 +157,13 @@ const AppComponent: m.Component> = { }, 'Add child' ), - m('h2', 'Growth Chart'), + m('h2#growth-chart', 'Growth Chart'), m(ChartSelectorComponent, { state: state.chart, actions: ChartActions(state.chart), }), m(ChartComponent, state.chart), - m('h2', 'Your Data'), + m('h2#your-data', 'Your Data'), m(DataManagementComponent, {state, actions}), ]; },