Skip to content

Commit 42e22fe

Browse files
committed
feat: Integrate Ikon API for snow report data, adding client, resort configuration, documentation, and pass logos.
1 parent d16940a commit 42e22fe

File tree

9 files changed

+443
-4
lines changed

9 files changed

+443
-4
lines changed

doc/ikon_app_api_calls.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# ikon mobile app api calls
2+
3+
ikon uses an api exposed by mtnpowder.com to get resort data.
4+
it's private and undocumented and uses a bearer token that appears to be a base64 encoded string that looks like a
5+
sha256 hash. it does not appear to change between logins.
6+
7+
there does not appear to be a way to get the bearer token without logging in to the app while instrumenting the device
8+
through an intercepting proxy that is terminating SSL
9+
10+
## get all resorts
11+
`curl -H 'accept: */*' -H 'content-type: application/json' \
12+
-H 'user-agent: IkonMobileIOS/7.76.304 (The in-house Ikon mobile app; iPadOS; Apple; Build 9308)' \
13+
-H 'accept-language: en-US,en;q=0.9' --compressed \
14+
'https://www.mtnpowder.com/feed/v3/ikon.json?bearer_token=$IKON_API_KEY'`
15+
16+
17+
18+
## get resort data
19+
`curl -H 'accept: */*' -H 'content-type: application/json' \
20+
-H 'user-agent: IkonMobileIOS/7.76.304 (The in-house Ikon mobile app; iPadOS; Apple; Build 9308)' \
21+
-H 'accept-language: en-US,en;q=0.9' --compressed \
22+
'https://www.mtnpowder.com/feed/v3.json?resortId=5&bearer_token="$IKON_API_KEY"' | jq .`
23+
24+

doc/ikon_resort_ids.md

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# Ikon Resort ID Map
2+
3+
| Resort Name | ID |
4+
|---|---|
5+
| Stratton | 1 |
6+
| Snowshoe | 2 |
7+
| Blue | 3 |
8+
| Tremblant | 4 |
9+
| Winter Park | 5 |
10+
| Steamboat | 6 |
11+
| Stratton Summer | 7 |
12+
| Snowshoe Summer | 8 |
13+
| Blue Summer | 9 |
14+
| Tremblant Summer | 10 |
15+
| Winter Park Summer | 11 |
16+
| Steamboat Summer | 12 |
17+
| Deer Valley | 49 |
18+
| Deer Valley Summer | 50 |
19+
| Aspen Highlands | 51 |
20+
| Aspen Mountain | 52 |
21+
| Buttermilk | 53 |
22+
| Snowmass | 54 |
23+
| Copper Mountain | 55 |
24+
| Eldora | 56 |
25+
| Bear Mountain | 57 |
26+
| Snow Summit | 58 |
27+
| June Mountain | 59 |
28+
| Mammoth Mountain | 60 |
29+
| Palisades Tahoe | 61 |
30+
| Palisades Tahoe - Alpine Meadows Legacy | 62 |
31+
| Alta | 63 |
32+
| Snowbird | 64 |
33+
| Solitude | 65 |
34+
| Brighton | 66 |
35+
| Jackson Hole | 67 |
36+
| Big Sky | 68 |
37+
| Killington | 69 |
38+
| Sugarbush | 70 |
39+
| Loon Mountain | 71 |
40+
| Sugarloaf | 72 |
41+
| Sunday River | 73 |
42+
| Lake Louise | 74 |
43+
| Mt Norquay | 75 |
44+
| Sunshine Village | 76 |
45+
| Revelstoke | 77 |
46+
| Taos | 78 |
47+
| Summit at Snoqualmie | 79 |
48+
| Crystal Mountain | 80 |
49+
| Cypress Mountain | 81 |
50+
| Boyne Highlands | 82 |
51+
| Boyne Mountain | 83 |
52+
| Niseko Annupuri | 84 |
53+
| Niseko Grand Hirafu | 85 |
54+
| Niseko Hanazano | 86 |
55+
| Niseko Village | 87 |
56+
| Bear Mountain Summer | 88 |
57+
| Snow Summit Summer | 89 |
58+
| Mammoth Mountain Summer | 90 |
59+
| Arapahoe Basin | 93 |
60+
| Pico Mountain | 94 |
61+
| Solitude Summer | 95 |
62+
| Mt Bachelor | 96 |
63+
| Windham Mountain | 97 |
64+
| RED Mountain | 98 |
65+
| Sugarbush Summer | 131 |
66+
| Snowbasin Resort | 132 |
67+
| Chamonix | 134 |
68+
| Tamarack Cross Country Ski Center | 135 |
69+
| Schweitzer | 168 |
70+
| Lotte Arai | 169 |
71+
| Panorama Mountain Resort | 170 |
72+
| Sun Peaks | 171 |
73+
| Grandvilara | 172 |
74+
| Snow Valley | 173 |
75+
| Snow Valley Summer | 174 |
76+
| Alyeska | 175 |
77+
| Blue Mountain (PA) | 176 |
78+
| Camelback | 177 |
79+
| Mt Bueller | 178 |
80+
| Coronet Peak | 179 |
81+
| The Remarkables | 180 |
82+
| Mt Hutt | 181 |
83+
| Thredbo | 182 |
84+
| Valle Nevado | 183 |
85+
| Cortina d'Ampezzo | 185 |
86+
| Kronplatz/Plan de Corones | 186 |
87+
| Alta Badia | 187 |
88+
| Val Gardena/Alpe de Siusi | 188 |
89+
| Val di Fassa/Carezza | 190 |
90+
| Arabba/Marmolada | 193 |
91+
| 3 Peaks Dolomites | 195 |
92+
| Val di Fiemme/Obereggen | 196 |
93+
| San Martino di Castrozza/Rolle Pass | 198 |
94+
| Civetta | 202 |
95+
| Kitzbühel | 206 |
96+
| St Moritz | 208 |
97+
| Alpental | 211 |
98+
| Zermatt Matterhorn | 212 |
99+
| Rio Pusteria - Bressanone | 213 |
100+
| Alpe Lusia - San Pellegrino | 214 |
101+
| Sierra at Tahoe | 215 |
102+
| Ischgl | 216 |
103+
| Le Massif | 217 |
104+
| Schweitzer Summer | 218 |
105+
| Mt. T | 219 |
106+
| Nekoma | 221 |
107+
| Yunding Snow Park | 222 |
108+
| Cervino Ski Paradise | 223 |
109+
| Courmayeur Mont Blanc | 224 |
110+
| Espace San Bernardo | 225 |
111+
| Monterosa Ski | 226 |
112+
| Pila | 227 |
113+
| Yakebitaiyama Ski Area | 241 |
114+
| Okushiga Kogen Ski Area | 242 |
115+
| Kumanoyu Ski Area | 243 |
116+
| Yokoteyama Ski Area | 244 |
117+
| Sun Valley | 248 |
118+
| Megeve | 249 |
119+
| Mona Yongpyong | 250 |
120+
| Furano Ski Resort | 251 |
121+
| Appi Kogen Resort | 252 |
122+
| Zao Onsen Ski Resort | 253 |
123+
| Sun Valley Ski Area | 254 |
124+
| Maruike Ski Area | 255 |
125+
| Hasuike Ski Area | 256 |
126+
| Giant Ski Area | 257 |
127+
| Nishidateyama Ski Area | 258 |
128+
| Higashidateyama Ski Area | 259 |
129+
| Hoppo Bunadaira Ski Area | 260 |
130+
| Terakoya Ski Area | 261 |
131+
| Takamagahara Mommoth Ski Area | 262 |
132+
| Tannenomori Okojo Ski Area | 264 |
133+
| Ichinose Family Ski Area | 265 |
134+
| Ichinose Diamond Ski Area | 266 |
135+
| Ichinose Yamanokami Ski Area | 267 |
136+
| Shibutoge Ski Area | 269 |
137+
| Myoko Suginohara | 270 |

next-env.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/// <reference types="next" />
22
/// <reference types="next/image-types/global" />
3-
import "./.next/types/routes.d.ts";
3+
import "./.next/dev/types/routes.d.ts";
44

55
// NOTE: This file should not be edited
66
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

public/epic_pass_logo.png

2.16 KB
Loading

public/ikon_pass_logo.png

3.74 KB
Loading

scripts/verify-ikon.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
2+
// scripts/verify-ikon.ts
3+
import { ikonClient } from '../src/services/ikon/client';
4+
import { IkonApiProvider } from '../src/services/snow-report/providers/ikon-api';
5+
6+
// Simple polyfill for fetch if needed (Node 18+ has it native)
7+
// env vars should be loaded by run_command environment or dotenv
8+
9+
async function verify() {
10+
console.log("Verifying Ikon Client...");
11+
try {
12+
console.log("Fetching Winter Park (ID 5)...");
13+
const wpData = await ikonClient.getResortStatus(5);
14+
console.log("Winter Park Raw Data Summary:");
15+
console.log(`Lifts: ${wpData.lifts.length}`);
16+
console.log(`Trails: ${wpData.trails.length}`);
17+
console.log(`Parks: ${wpData.terrainParks.length}`);
18+
19+
if (wpData.lifts.length > 0) {
20+
console.log("Sample Lift:", wpData.lifts[0]);
21+
}
22+
23+
console.log("\nVerifying IkonApiProvider...");
24+
const provider = new IkonApiProvider();
25+
const status = await provider.getStatus('winterpark'); // Internal ID
26+
27+
if (status) {
28+
console.log("Provider Status Result:");
29+
console.log(JSON.stringify(status.summary, null, 2));
30+
console.log("Lifts Sample:", Object.entries(status.lifts).slice(0, 3));
31+
} else {
32+
console.error("Provider returned null for 'winterpark'");
33+
}
34+
35+
} catch (e) {
36+
console.error("Verification failed:", e);
37+
}
38+
}
39+
40+
verify();

src/config/ikon-resorts.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
2+
export interface IkonResortConfig {
3+
ikonId: number;
4+
}
5+
6+
export const IKON_RESORT_MAP: Record<string, IkonResortConfig> = {
7+
'winterpark': { ikonId: 5 },
8+
'steamboat': { ikonId: 6 },
9+
'eldora': { ikonId: 13 },
10+
// Aspen generic mapped to Aspen Mountain for MVP
11+
'aspen': { ikonId: 52 },
12+
13+
// Utah
14+
'deervalley': { ikonId: 49 },
15+
'alta': { ikonId: 78 },
16+
'snowbird': { ikonId: 77 },
17+
'brighton': { ikonId: 14 },
18+
'solitude': { ikonId: 65 },
19+
20+
// Tahoe
21+
'palisades': { ikonId: 61 },
22+
// Alpine Meadows often has its own ID but sometimes rolled into Palisades.
23+
// Based on resort list, distinct ID not always clear in summary, but verified ID 61 is Palisades.
24+
// We will map Alpine to 61 for now as well unless we find a specific one.
25+
// Actually, let's stick to 61 for both for now or check if there's another.
26+
// Wait, recent resort list had "Palisades Tahoe" but didn't explicitly list Alpine as a separate top-level resort with ID?
27+
// Let's assume 61 covers it or it's the main one.
28+
'alpinemeadows': { ikonId: 61 },
29+
'mammoth': { ikonId: 60 }
30+
};

src/services/ikon/client.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
2+
import { logger } from "@/utils/logger";
3+
4+
const IKON_API_BASE = "https://www.mtnpowder.com/feed/v3.json";
5+
const USER_AGENT = "IkonMobileIOS/7.76.304 (The in-house Ikon mobile app; iPadOS; Apple; Build 9308)";
6+
7+
export interface IkonLift {
8+
Name: string;
9+
Status: string; // "Open", "Closed", "Scheduled", "Hold", etc.
10+
// ... other fields ignore for now
11+
}
12+
13+
export interface IkonTrail {
14+
Name: string;
15+
Status: string; // "Open", "Closed"
16+
TrailIcon: string; // "GreenCircle", "BlueSquare", "BlackDiamond", "Park", etc.
17+
Difficulty: string;
18+
// ...
19+
}
20+
21+
export interface IkonMountainArea {
22+
Name: string;
23+
Lifts: IkonLift[];
24+
Trails: IkonTrail[];
25+
}
26+
27+
export interface IkonResortResponse {
28+
Name: string;
29+
OperatingStatus: string;
30+
MountainAreas: IkonMountainArea[];
31+
// ...
32+
}
33+
34+
export class IkonClient {
35+
private getApiKey(): string {
36+
const key = process.env.IKON_API_KEY;
37+
if (!key) {
38+
logger.error("IkonClient: IKON_API_KEY not found in environment variables");
39+
throw new Error("IKON_API_KEY missing");
40+
}
41+
return key;
42+
}
43+
44+
private async fetch<T>(params: URLSearchParams): Promise<T> {
45+
const token = this.getApiKey();
46+
params.append("bearer_token", token);
47+
48+
const url = `${IKON_API_BASE}?${params.toString()}`;
49+
50+
logger.debug("IkonClient: Fetching URL", { url: url.replace(token, '[REDACTED]') });
51+
52+
try {
53+
const response = await fetch(url, {
54+
headers: {
55+
"User-Agent": USER_AGENT,
56+
"Accept": "application/json",
57+
"Accept-Language": "en-US,en;q=0.9"
58+
},
59+
next: { revalidate: 60 }
60+
});
61+
62+
if (!response.ok) {
63+
const errorText = await response.text();
64+
logger.error("IkonClient: HTTP Error", {
65+
status: response.status,
66+
statusText: response.statusText,
67+
body: errorText
68+
});
69+
throw new Error(`Ikon API error: ${response.status} ${response.statusText}`);
70+
}
71+
72+
const data = await response.json();
73+
logger.debug("IkonClient: Fetch success", { size: JSON.stringify(data).length });
74+
return data;
75+
} catch (error) {
76+
logger.error("IkonClient: Network or Parse Error", { error });
77+
throw error;
78+
}
79+
}
80+
81+
async getResortStatus(resortId: number) {
82+
logger.debug("IkonClient: getResortStatus called", { resortId });
83+
84+
const params = new URLSearchParams();
85+
params.append("resortId", resortId.toString());
86+
87+
try {
88+
const data = await this.fetch<IkonResortResponse>(params);
89+
90+
if (!data.MountainAreas) {
91+
logger.warn("IkonClient: No MountainAreas found in response", { resortId });
92+
return { lifts: [], trails: [], terrainParks: [] };
93+
}
94+
95+
const allLifts: IkonLift[] = [];
96+
const allTrails: IkonTrail[] = [];
97+
98+
data.MountainAreas.forEach(area => {
99+
if (area.Lifts) {
100+
logger.debug("IkonClient: Processing area lifts", { area: area.Name, count: area.Lifts.length });
101+
allLifts.push(...area.Lifts);
102+
}
103+
if (area.Trails) {
104+
logger.debug("IkonClient: Processing area trails", { area: area.Name, count: area.Trails.length });
105+
allTrails.push(...area.Trails);
106+
}
107+
});
108+
109+
const terrainParks = allTrails.filter(t => t.TrailIcon === "Park");
110+
111+
logger.info("IkonClient: Data processed successfully", {
112+
resortId,
113+
totalLifts: allLifts.length,
114+
totalTrails: allTrails.length,
115+
terrainParks: terrainParks.length
116+
});
117+
118+
return {
119+
lifts: allLifts,
120+
trails: allTrails,
121+
terrainParks
122+
};
123+
124+
} catch (error) {
125+
logger.error("IkonClient: Failed to get resort status", { resortId, error });
126+
throw error;
127+
}
128+
}
129+
}
130+
131+
export const ikonClient = new IkonClient();

0 commit comments

Comments
 (0)