Skip to content

Commit 5cd8592

Browse files
committed
Updated forecase template
1 parent 3981e9f commit 5cd8592

3 files changed

Lines changed: 115 additions & 60 deletions

File tree

src/lib/format-forecast.ts

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import type { ForecastProduct, DangerRatingValue } from "../services/avalanche-ca";
1+
import type { ForecastProduct, DangerRatingValue, AvalancheProblem } from "../services/avalanche-ca";
22

33
const MAX_LENGTH = 160;
4+
const MAX_NAME_LENGTH = 25;
45

56
const RATING_LABELS: Record<DangerRatingValue, string> = {
67
low: "Low",
@@ -11,6 +12,8 @@ const RATING_LABELS: Record<DangerRatingValue, string> = {
1112
noRating: "N/A",
1213
};
1314

15+
const ASPECT_ORDER = ["n", "ne", "e", "se", "s", "sw", "w", "nw"];
16+
1417
function formatDate(isoDate: string): string {
1518
const d = new Date(isoDate);
1619
const months = [
@@ -29,6 +32,27 @@ function truncate(text: string, maxLen: number): string {
2932
return text.slice(0, maxLen - 1) + "\u2026";
3033
}
3134

35+
function formatAspects(problem: AvalancheProblem): string {
36+
const aspects = problem.data?.aspects;
37+
if (!aspects || aspects.length === 0) return "";
38+
39+
// Sort aspects in compass order and uppercase
40+
const sorted = aspects
41+
.map((a) => a.value)
42+
.sort((a, b) => ASPECT_ORDER.indexOf(a) - ASPECT_ORDER.indexOf(b))
43+
.map((v) => v.toUpperCase());
44+
45+
if (sorted.length === 8) return "All aspects";
46+
47+
return sorted.join(",");
48+
}
49+
50+
function formatProblem(problem: AvalancheProblem): string {
51+
const aspects = formatAspects(problem);
52+
if (aspects) return `${problem.type.display} (${aspects})`;
53+
return problem.type.display;
54+
}
55+
3256
export function formatForecast(forecast: ForecastProduct): string {
3357
const report = forecast.report;
3458
const today = report.dangerRatings[0];
@@ -37,43 +61,30 @@ export function formatForecast(forecast: ForecastProduct): string {
3761
const tln = ratingLabel(today.ratings.tln.rating.value);
3862
const btl = ratingLabel(today.ratings.btl.rating.value);
3963

64+
const name = truncate(report.title, MAX_NAME_LENGTH);
4065
const dangerLine = `Alp:${alp} TL:${tln} BT:${btl}`;
4166
const dateLine = formatDate(report.dateIssued);
4267
const footer = "avalanche.ca";
4368

44-
// Fixed parts: date + newline + danger + newline + footer + surrounding newlines
45-
// Template: {name}\n{date}\n{danger}\n{problems}\n{footer}
46-
// Template: {name}\n{date}\n{danger}\n{footer} (no problems fallback)
47-
const fixedWithoutName = `\n${dateLine}\n${dangerLine}\n${footer}`;
48-
const maxNameLen = MAX_LENGTH - fixedWithoutName.length;
49-
50-
const name = truncate(report.title, maxNameLen);
51-
52-
// Build without problems first to see how much room we have
5369
const baseMessage = `${name}\n${dateLine}\n${dangerLine}\n${footer}`;
5470

55-
if (baseMessage.length >= MAX_LENGTH) {
71+
if (baseMessage.length >= MAX_LENGTH || report.problems.length === 0) {
5672
return baseMessage.slice(0, MAX_LENGTH);
5773
}
5874

59-
// Try to fit problems
60-
const problems = report.problems.map((p) => p.type.display);
61-
if (problems.length === 0) {
62-
return baseMessage;
63-
}
64-
65-
// Available space for problems line (plus the newline before it)
66-
const available = MAX_LENGTH - baseMessage.length - 1; // -1 for \n before problems
75+
// Available space for problems line (plus the \n before it)
76+
const available = MAX_LENGTH - baseMessage.length - 1;
6777

6878
if (available < 3) {
6979
return baseMessage;
7080
}
7181

7282
// Add problems one at a time until we run out of room
7383
let problemStr = "";
74-
for (let i = 0; i < problems.length; i++) {
84+
for (let i = 0; i < report.problems.length; i++) {
85+
const entry = formatProblem(report.problems[i]);
7586
const separator = i === 0 ? "" : ", ";
76-
const candidate = problemStr + separator + problems[i];
87+
const candidate = problemStr + separator + entry;
7788
if (candidate.length <= available) {
7889
problemStr = candidate;
7990
} else {

src/services/avalanche-ca.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ export type DangerRating = {
2525

2626
export type AvalancheProblem = {
2727
type: { value: string; display: string };
28+
data?: {
29+
aspects?: { value: string; display: string }[];
30+
};
2831
};
2932

3033
export type ForecastReport = {

test/format-forecast.test.ts

Lines changed: 80 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,23 @@
11
import { describe, it, expect } from "vitest";
22
import { formatForecast } from "../src/lib/format-forecast";
3-
import type { ForecastProduct } from "../src/services/avalanche-ca";
3+
import type { ForecastProduct, AvalancheProblem } from "../src/services/avalanche-ca";
4+
5+
type ProblemInput = {
6+
name: string;
7+
aspects?: string[];
8+
};
9+
10+
function makeProblem(input: string | ProblemInput): AvalancheProblem {
11+
if (typeof input === "string") {
12+
return { type: { value: input.toLowerCase(), display: input } };
13+
}
14+
return {
15+
type: { value: input.name.toLowerCase(), display: input.name },
16+
data: input.aspects
17+
? { aspects: input.aspects.map((a) => ({ value: a, display: a })) }
18+
: undefined,
19+
};
20+
}
421

522
function makeForecast(
623
overrides: Partial<{
@@ -9,7 +26,7 @@ function makeForecast(
926
alp: string;
1027
tln: string;
1128
btl: string;
12-
problems: string[];
29+
problems: Array<string | ProblemInput>;
1330
}> = {}
1431
): ForecastProduct {
1532
const {
@@ -18,7 +35,10 @@ function makeForecast(
1835
alp = "considerable",
1936
tln = "moderate",
2037
btl = "low",
21-
problems = ["Wind slab", "Storm slab"],
38+
problems = [
39+
{ name: "Wind slab", aspects: ["n", "ne", "e"] },
40+
{ name: "Storm slab", aspects: ["n", "ne", "e", "se", "s", "sw", "w", "nw"] },
41+
],
2242
} = overrides;
2343

2444
return {
@@ -33,79 +53,101 @@ function makeForecast(
3353
{
3454
date: { value: dateIssued, display: "Monday" },
3555
ratings: {
36-
alp: {
37-
display: "Alpine",
38-
rating: { value: alp as any, display: "" },
39-
},
40-
tln: {
41-
display: "Treeline",
42-
rating: { value: tln as any, display: "" },
43-
},
44-
btl: {
45-
display: "Below Treeline",
46-
rating: { value: btl as any, display: "" },
47-
},
56+
alp: { display: "Alpine", rating: { value: alp as any, display: "" } },
57+
tln: { display: "Treeline", rating: { value: tln as any, display: "" } },
58+
btl: { display: "Below Treeline", rating: { value: btl as any, display: "" } },
4859
},
4960
},
5061
],
51-
problems: problems.map((p) => ({ type: { value: p.toLowerCase(), display: p } })),
62+
problems: problems.map(makeProblem),
5263
},
5364
};
5465
}
5566

5667
describe("formatForecast", () => {
57-
it("formats a standard forecast within 160 chars", () => {
68+
it("formats a forecast with aspects within 160 chars", () => {
5869
const result = formatForecast(makeForecast());
59-
expect(result).toBe(
60-
"Kananaskis\nFeb 23\nAlp:Considerable TL:Moderate BT:Low\nWind slab, Storm slab\navalanche.ca"
61-
);
70+
expect(result).toContain("Wind slab (N,NE,E)");
71+
expect(result).toContain("Storm slab (All aspects)");
6272
expect(result.length).toBeLessThanOrEqual(160);
6373
});
6474

6575
it("never exceeds 160 characters", () => {
6676
const result = formatForecast(
6777
makeForecast({
68-
title:
69-
"Badshot-Battle-Central Selkirk-Esplanade-Goat-Gold-Jordan-North Monashee-North Selkirk-Retallack-West Purcell",
70-
problems: ["Storm slab", "Persistent slab", "Wind slab", "Deep persistent slab"],
78+
title: "Badshot-Battle-Central Selkirk-Esplanade-Goat-Gold-Jordan-North Monashee",
79+
problems: [
80+
{ name: "Storm slab", aspects: ["n", "ne", "e", "se"] },
81+
{ name: "Persistent slab", aspects: ["n", "ne", "nw"] },
82+
{ name: "Wind slab", aspects: ["e", "se", "s"] },
83+
],
7184
})
7285
);
7386
expect(result.length).toBeLessThanOrEqual(160);
7487
});
7588

76-
it("truncates long region names with ellipsis", () => {
89+
it("truncates region name to 25 chars with ellipsis", () => {
7790
const result = formatForecast(
7891
makeForecast({
79-
title:
80-
"Badshot-Battle-Central Selkirk-Esplanade-Goat-Gold-Jordan-North Monashee-North Selkirk-Retallack-West Purcell",
92+
title: "Badshot-Battle-Central Selkirk-Esplanade",
93+
problems: [],
8194
})
8295
);
83-
expect(result).toContain("\u2026");
96+
expect(result.startsWith("Badshot-Battle-Central S\u2026")).toBe(true);
8497
expect(result.length).toBeLessThanOrEqual(160);
8598
});
8699

100+
it("does not truncate short region names", () => {
101+
const result = formatForecast(makeForecast({ title: "Kananaskis" }));
102+
expect(result).toContain("Kananaskis\n");
103+
});
104+
87105
it("handles no problems", () => {
88106
const result = formatForecast(makeForecast({ problems: [] }));
89107
expect(result).toBe(
90108
"Kananaskis\nFeb 23\nAlp:Considerable TL:Moderate BT:Low\navalanche.ca"
91109
);
92-
expect(result.length).toBeLessThanOrEqual(160);
93110
});
94111

95-
it("truncates problem list to fit", () => {
112+
it("handles problems without aspects", () => {
113+
const result = formatForecast(makeForecast({ problems: ["Wind slab", "Storm slab"] }));
114+
expect(result).toContain("Wind slab, Storm slab");
115+
});
116+
117+
it("shows 'All aspects' when all 8 directions present", () => {
118+
const result = formatForecast(
119+
makeForecast({
120+
problems: [
121+
{ name: "Storm slab", aspects: ["n", "ne", "e", "se", "s", "sw", "w", "nw"] },
122+
],
123+
})
124+
);
125+
expect(result).toContain("Storm slab (All aspects)");
126+
});
127+
128+
it("sorts aspects in compass order", () => {
129+
const result = formatForecast(
130+
makeForecast({
131+
problems: [
132+
{ name: "Wind slab", aspects: ["sw", "n", "e"] },
133+
],
134+
})
135+
);
136+
expect(result).toContain("Wind slab (N,E,SW)");
137+
});
138+
139+
it("truncates problem list to fit within 160 chars", () => {
96140
const result = formatForecast(
97141
makeForecast({
98-
title: "A Fairly Long Region Name Here",
99142
problems: [
100-
"Storm slab",
101-
"Persistent slab",
102-
"Wind slab",
103-
"Deep persistent slab",
143+
{ name: "Storm slab", aspects: ["n", "ne", "e", "se"] },
144+
{ name: "Persistent slab", aspects: ["n", "ne", "nw"] },
145+
{ name: "Wind slab", aspects: ["e", "se", "s"] },
146+
{ name: "Deep persistent slab", aspects: ["n", "ne"] },
104147
],
105148
})
106149
);
107150
expect(result.length).toBeLessThanOrEqual(160);
108-
// Should include at least the first problem
109151
expect(result).toContain("Storm slab");
110152
});
111153

@@ -121,7 +163,7 @@ describe("formatForecast", () => {
121163

122164
for (const [value, display] of cases) {
123165
const result = formatForecast(
124-
makeForecast({ alp: value, tln: value, btl: value })
166+
makeForecast({ alp: value, tln: value, btl: value, problems: [] })
125167
);
126168
expect(result).toContain(`Alp:${display}`);
127169
expect(result).toContain(`TL:${display}`);
@@ -131,7 +173,7 @@ describe("formatForecast", () => {
131173

132174
it("formats date correctly", () => {
133175
const result = formatForecast(
134-
makeForecast({ dateIssued: "2026-12-05T00:00:00Z" })
176+
makeForecast({ dateIssued: "2026-12-05T00:00:00Z", problems: [] })
135177
);
136178
expect(result).toContain("Dec 5");
137179
});
@@ -142,8 +184,7 @@ describe("formatForecast", () => {
142184
});
143185

144186
it("uses first day danger ratings", () => {
145-
const forecast = makeForecast({ alp: "high", tln: "considerable", btl: "moderate" });
146-
// Add a second day
187+
const forecast = makeForecast({ alp: "high", tln: "considerable", btl: "moderate", problems: [] });
147188
forecast.report.dangerRatings.push({
148189
date: { value: "2026-02-25T00:00:00Z", display: "Tuesday" },
149190
ratings: {

0 commit comments

Comments
 (0)