/** @jsx m */ /** @jsxFrag m.fragment */ import { WeatherApi, WeatherData, WeatherSample } from "./types.js"; import { checkForError } from "./api.js"; import { getWeatherIcon, triangleAlert, hotAlert, coldAlert } from "./weather_icons.js"; import { MockWeatherApi } from "./mock_api.js"; import { addTest, TestCase } from "./third_party/test.js"; function formatIconName(iconName: string): string { if (!iconName) { return ""; } return iconName .split("-") .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(" "); } function hasAlerts(sample: WeatherSample): boolean { return sample.alerts !== null && sample.alerts !== undefined && sample.alerts.length > 0; } interface ChartColumn { x: number; dayLabel: string; maxY: number; minY: number; } function pluckField(columns: C[], extract: (col: C) => T): T[] { return columns.map(extract); } function buildPolylinePoints(xs: number[], ys: number[]): string { return xs.map((x, i) => `${x},${ys[i]}`).join(" "); } function renderTempChart(samples: WeatherSample[], coldThreshold: number, hotThreshold: number, tempUnit: string): MithrilRawElement { const svgWidth = 520; const svgHeight = 80; const marginLeft = 30; const marginRight = 0; const marginTop = 5; const marginBottom = 5; const chartWidth = svgWidth - marginLeft - marginRight; const chartHeight = svgHeight - marginTop - marginBottom; // Scale range based on unit const tempMin = tempUnit === "C" ? -20 : 0; const tempMax = tempUnit === "C" ? 40 : 100; const gridStep = 20; // Map temperature to Y coordinate (higher temp = lower Y) function tempToY(temp: number): number { return marginTop + chartHeight * (1 - (temp - tempMin) / (tempMax - tempMin)); } // Map sample index to X coordinate function indexToX(i: number): number { if (samples.length <= 1) { return marginLeft + chartWidth / 2; } const xPerSample = chartWidth / samples.length; return marginLeft + (i + .5) * xPerSample; } // Horizontal grid lines at regular intervals const gridLines: MithrilRawElement[] = []; const yLabels: MithrilRawElement[] = []; for (let temp = tempMin; temp <= tempMax; temp += gridStep) { const y = tempToY(temp); gridLines.push( ); yLabels.push( {temp}° ); } const columns: ChartColumn[] = samples.map((s, i) => ({ x: indexToX(i), dayLabel: s.date.substring(0, 3), maxY: tempToY(s.tempMax), minY: tempToY(s.tempMin), })); const xs = pluckField(columns, (c) => { return c.x; }); // const xLabels = columns.map((col) => { // return ( // {col.dayLabel} // ); // }); const maxPoints = buildPolylinePoints(xs, pluckField(columns, (c) => { return c.maxY; })); const minPoints = buildPolylinePoints(xs, pluckField(columns, (c) => { return c.minY; })); const maxdots = columns.map((col) => { return ( ); }); const mindots = columns.map((col) => { return ( ); }); // Background temperature range zones const hotZoneY = tempToY(tempMax); const hotZoneHeight = tempToY(hotThreshold) - hotZoneY; const coldZoneY = tempToY(coldThreshold); const coldZoneHeight = tempToY(tempMin) - coldZoneY; const chartX = marginLeft; const chartFullWidth = svgWidth - marginLeft - marginRight; // // // {xLabels} return ( {hotZoneHeight > 0 ? : null} {coldZoneHeight > 0 ? : null} {gridLines} {yLabels} {maxdots} {mindots} ); } export class WeatherComponent { private api: WeatherApi; private tenantId: string; private account: string; private orderId: string; private weatherData: WeatherData | null; private errorMessage: string; private loading: boolean; private selectedColumn: number; constructor(api: WeatherApi, tenantId: string, account: string, orderId: string) { this.api = api; this.tenantId = tenantId; this.account = account; this.orderId = orderId; this.weatherData = null; this.errorMessage = ""; this.loading = true; this.selectedColumn = -1; } oninit(vnode: MithrilVnode) { this.loadData(); } loadData(): void { this.loading = true; this.api.getWeatherData(this.tenantId, this.account, this.orderId).then((resp) => { if (!checkForError(resp)) { this.errorMessage = resp.error || "unknown error"; } else { this.weatherData = resp.data || null; } this.loading = false; m.redraw(); }); } private onColumnClick(index: number): void { this.selectedColumn = index; } view(vnode: MithrilVnode): MithrilRawElement { if (this.loading) { return
Loading...
; } if (this.errorMessage) { return
{this.errorMessage}
; } if (!this.weatherData) { return
no data
; } const samples = this.weatherData.samples; const coldThreshold = this.weatherData.coldThreshold; const hotThreshold = this.weatherData.hotThreshold; const tempUnit = this.weatherData.tempUnit; console.log("shippingDistance:", this.weatherData.shippingDistance, "shippingDays:", this.weatherData.shippingDays); const self = this; interface ViewColumn { index: number; colClass: string; onclick: () => void; dayLabel: string; iconTitle: string; iconSvg: string; tempMax: number; tempMin: number; isAlert: boolean; isHot: boolean; isCold: boolean; firstAlertEvent: string; } const viewColumns: ViewColumn[] = samples.map((s, i) => { const classes = [`col-${i}`]; if (i === this.selectedColumn) { classes.push("selected"); } const isAlert = hasAlerts(s); const isCold = s.tempMin < coldThreshold; const isHot = s.tempMax > hotThreshold; return { index: i, colClass: classes.join(" "), onclick: () => { self.onColumnClick(i); }, dayLabel: s.date.substring(0, 3), iconTitle: formatIconName(s.icon), iconSvg: getWeatherIcon(s.icon), tempMax: Math.round(s.tempMax), tempMin: Math.round(s.tempMin), isAlert: isAlert, isHot: isHot, isCold: isCold, firstAlertEvent: hasAlerts(s) ? s.alerts[0].event : "", }; }); const dayRow1 = pluckField(viewColumns, (col) => { return
{col.dayLabel}
; }); const dayRow2 = pluckField(viewColumns, (col) => { return
{col.dayLabel}
; }); const iconRow = pluckField(viewColumns, (col) => { return (
{m.trust(col.iconSvg)}
); }); const tempRow = pluckField(viewColumns, (col) => { let highlightElement = null; return (
{col.tempMax}° / {col.tempMin}° {highlightElement}
); }); const alertIconRow = pluckField(viewColumns, (col) => { const alertIcons: MithrilRawElement[] = []; if (col.isAlert) { alertIcons.push( {m.trust(triangleAlert)} ); } if (col.isCold) { alertIcons.push( {m.trust(coldAlert)} ); } if (col.isHot) { alertIcons.push( {m.trust(hotAlert)} ); } return (
{alertIcons}
); }); const alertRow = pluckField(viewColumns, (col) => { return (
{col.firstAlertEvent ? {col.firstAlertEvent} : null}
); }); const overlayRow = pluckField(viewColumns, (col) => { return (
); }); let alertPanel: MithrilRawElement | string = ""; if (this.selectedColumn >= 0 && this.selectedColumn < samples.length) { const selected = samples[this.selectedColumn]; if (hasAlerts(selected)) { const alertDetails = selected.alerts.map((a) => (

{a.headline}

{a.description}

)); alertPanel = (
{alertDetails}
); } } const tempChart = renderTempChart(samples, coldThreshold, hotThreshold, tempUnit); return (

{samples.length}-Day Forecast for {this.weatherData.zipCode}

{dayRow1} {iconRow} {tempRow} {alertIconRow} {alertRow}
{tempChart}
{overlayRow} {dayRow2}
{alertPanel}
); } } addTest("WeatherComponent view renders with data", (t: TestCase) => { const api = new MockWeatherApi({ data: { zipCode: "90210", tempUnit: "F", coldThreshold: 45, hotThreshold: 80, shippingDistance: -1, shippingDays: -1, samples: [ { date: "Mon Jan 13", tempMin: 32, tempMax: 72, icon: "clear-day", alerts: [ { event: "Freeze Warning", headline: "Cold temps", description: "Below freezing" }, ], }, { date: "Tue Jan 14", tempMin: 40, tempMax: 65, icon: "rain", alerts: [], }, ], }, }); const component = new WeatherComponent(api, "t1", "jwt", "order1"); component["weatherData"] = api["response"].data!; component["loading"] = false; const result = component.view({} as MithrilVnode); t.defined(result); }); addTest("WeatherComponent view renders with selected alert column", (t: TestCase) => { const api = new MockWeatherApi({ data: { zipCode: "90210", tempUnit: "F", coldThreshold: 45, hotThreshold: 80, shippingDistance: -1, shippingDays: -1, samples: [ { date: "Mon Jan 13", tempMin: 32, tempMax: 72, icon: "clear-day", alerts: [ { event: "Freeze Warning", headline: "Cold temps", description: "Below freezing" }, ], }, ], }, }); const component = new WeatherComponent(api, "t1", "jwt", "order1"); component["weatherData"] = api["response"].data!; component["loading"] = false; component["selectedColumn"] = 0; const result = component.view({} as MithrilVnode); t.defined(result); }); addTest("WeatherComponent view renders loading state", (t: TestCase) => { const api = new MockWeatherApi(); const component = new WeatherComponent(api, "t1", "jwt", "order1"); const result = component.view({} as MithrilVnode); t.defined(result); }); addTest("WeatherComponent view renders error state", (t: TestCase) => { const api = new MockWeatherApi(); const component = new WeatherComponent(api, "t1", "jwt", "order1"); component["loading"] = false; component["errorMessage"] = "something went wrong"; const result = component.view({} as MithrilVnode); t.defined(result); }); addTest("formatIconName converts icon names to title case", (t: TestCase) => { t.equals("Partly Cloudy Day", formatIconName("partly-cloudy-day")); t.equals("Clear Day", formatIconName("clear-day")); t.equals("Rain", formatIconName("rain")); t.equals("Thunder Showers Night", formatIconName("thunder-showers-night")); }); addTest("formatIconName handles empty string", (t: TestCase) => { t.equals("", formatIconName("")); }); addTest("onColumnClick sets selectedColumn", (t: TestCase) => { const api = new MockWeatherApi(); const component = new WeatherComponent(api, "t1", "jwt", "order1"); t.equals(-1, component["selectedColumn"]); component["onColumnClick"](2); t.equals(2, component["selectedColumn"]); component["onColumnClick"](3); t.equals(3, component["selectedColumn"]); component["onColumnClick"](1); t.equals(1, component["selectedColumn"]); }); addTest("renderTempChart renders without error", (t: TestCase) => { const samples = [ { date: "Mon Jan 13", tempMin: 32, tempMax: 72, icon: "clear-day", alerts: [] }, { date: "Tue Jan 14", tempMin: 40, tempMax: 65, icon: "rain", alerts: [] }, { date: "Wed Jan 15", tempMin: 28, tempMax: 55, icon: "snow", alerts: [] }, ]; const result = renderTempChart(samples, 45, 80, "F"); t.defined(result); }); addTest("renderTempChart renders with single sample", (t: TestCase) => { const samples = [ { date: "Mon Jan 13", tempMin: 50, tempMax: 80, icon: "clear-day", alerts: [] }, ]; const result = renderTempChart(samples, 45, 80, "F"); t.defined(result); }); addTest("renderTempChart renders with Celsius", (t: TestCase) => { const samples = [ { date: "Mon Jan 13", tempMin: 5, tempMax: 25, icon: "clear-day", alerts: [] }, ]; const result = renderTempChart(samples, 7, 27, "C"); t.defined(result); }); addTest("checkForError returns true on success", (t: TestCase) => { t.true(checkForError({ data: { zipCode: "90210", samples: [], tempUnit: "F", coldThreshold: 45, hotThreshold: 80, shippingDistance: -1, shippingDays: -1 } })); }); addTest("checkForError returns false on error", (t: TestCase) => { t.false(checkForError({ error: "something broke" })); });