diff --git a/__tests__/client/components/AvalancheProblemCard.client.test.tsx b/__tests__/client/components/AvalancheProblemCard.client.test.tsx new file mode 100644 index 000000000..b5ec503c3 --- /dev/null +++ b/__tests__/client/components/AvalancheProblemCard.client.test.tsx @@ -0,0 +1,87 @@ +import { AvalancheProblemCard } from '@/components/forecast/AvalancheProblemCard' +import { + AvalancheProblemLikelihood, + AvalancheProblemLocation, + AvalancheProblemName, + AvalancheProblemType, + MediaType, + type AvalancheProblem, +} from '@/services/nac/types/forecastSchemas' +import '@testing-library/jest-dom' +import { render, screen } from '@testing-library/react' + +const baseProblem: AvalancheProblem = { + id: 1, + forecast_id: 100, + rank: 1, + avalanche_problem_id: AvalancheProblemType.StormSlab, + name: AvalancheProblemName.StormSlab, + likelihood: AvalancheProblemLikelihood.Likely, + location: [ + AvalancheProblemLocation.NorthUpper, + AvalancheProblemLocation.NorthMiddle, + AvalancheProblemLocation.NortheastUpper, + ], + size: [1, 2], + discussion: '

Watch for storm slabs on north-facing terrain.

', + problem_description: 'Storm Slab description', + icon: 'http://api.avalanche.org/img/avalanche_problems/StormSlab.png', + media: { type: MediaType.Unknown }, +} + +describe('AvalancheProblemCard', () => { + it('renders the problem name', () => { + render() + expect(screen.getByText('Storm Slab')).toBeInTheDocument() + }) + + it('renders the problem icon with correct src', () => { + render() + const icon = document.querySelector('img[src="/images/problem-icons/StormSlab.png"]') + expect(icon).toBeInTheDocument() + }) + + it('renders sanitized discussion HTML', () => { + render() + expect(screen.getByText('Watch for storm slabs on north-facing terrain.')).toBeInTheDocument() + }) + + it('does not render discussion when null', () => { + const problem = { ...baseProblem, discussion: null } + const { container } = render() + expect(container.querySelector('.prose')).not.toBeInTheDocument() + }) + + it('renders likelihood and size labels', () => { + render() + expect(screen.getByText('Likelihood')).toBeInTheDocument() + expect(screen.getByText('Size')).toBeInTheDocument() + }) + + it('renders a media thumbnail for image type', () => { + const problem: AvalancheProblem = { + ...baseProblem, + media: { + type: MediaType.Image, + url: { + large: 'https://example.com/large.jpg', + medium: 'https://example.com/medium.jpg', + original: 'https://example.com/original.jpg', + thumbnail: 'https://example.com/thumb.jpg', + }, + caption: 'Storm slab crown', + title: null, + }, + } + render() + const img = document.querySelector('img[src="https://example.com/thumb.jpg"]') + expect(img).toBeInTheDocument() + }) + + it('does not render a thumbnail for unknown media type', () => { + render() + const images = document.querySelectorAll('img') + // Only the problem icon, no media thumbnail + expect(images).toHaveLength(1) + }) +}) diff --git a/__tests__/server/dangerScale.server.test.ts b/__tests__/server/dangerScale.server.test.ts new file mode 100644 index 000000000..8dbda4391 --- /dev/null +++ b/__tests__/server/dangerScale.server.test.ts @@ -0,0 +1,58 @@ +import { dangerColor, dangerIconUrl, dangerName, dangerTextColor } from '@/services/nac/dangerScale' +import { DangerLevel } from '@/services/nac/types/forecastSchemas' + +describe('dangerName', () => { + it.each([ + [DangerLevel.Extreme, 'Extreme'], + [DangerLevel.High, 'High'], + [DangerLevel.Considerable, 'Considerable'], + [DangerLevel.Moderate, 'Moderate'], + [DangerLevel.Low, 'Low'], + [DangerLevel.None, 'No Rating'], + [DangerLevel.GeneralInformation, 'No Rating'], + ])('returns "%s" for level %i', (level, expected) => { + expect(dangerName(level)).toBe(expected) + }) +}) + +describe('dangerColor', () => { + it.each([ + [DangerLevel.Extreme, '#231f20'], + [DangerLevel.High, '#ed1c24'], + [DangerLevel.Considerable, '#f7941e'], + [DangerLevel.Moderate, '#fff200'], + [DangerLevel.Low, '#50b848'], + [DangerLevel.None, '#939598'], + [DangerLevel.GeneralInformation, '#6ea4db'], + ])('returns correct hex for level %i', (level, expected) => { + expect(dangerColor(level)).toBe(expected) + }) +}) + +describe('dangerTextColor', () => { + it.each([ + [DangerLevel.Extreme, '#ffffff'], + [DangerLevel.High, '#1a1a1a'], + [DangerLevel.Considerable, '#1a1a1a'], + [DangerLevel.Moderate, '#1a1a1a'], + [DangerLevel.Low, '#1a1a1a'], + [DangerLevel.None, '#1a1a1a'], + [DangerLevel.GeneralInformation, '#1a1a1a'], + ])('returns appropriate contrast color for level %i', (level, expected) => { + expect(dangerTextColor(level)).toBe(expected) + }) +}) + +describe('dangerIconUrl', () => { + it.each([ + [DangerLevel.Extreme, '/images/danger-icons/5.png'], + [DangerLevel.High, '/images/danger-icons/4.png'], + [DangerLevel.Considerable, '/images/danger-icons/3.png'], + [DangerLevel.Moderate, '/images/danger-icons/2.png'], + [DangerLevel.Low, '/images/danger-icons/1.png'], + [DangerLevel.None, '/images/danger-icons/0.png'], + [DangerLevel.GeneralInformation, '/images/danger-icons/0.png'], + ])('returns correct path for level %i', (level, expected) => { + expect(dangerIconUrl(level)).toBe(expected) + }) +}) diff --git a/__tests__/server/fixtures/nwac-forecast-active.json b/__tests__/server/fixtures/nwac-forecast-active.json new file mode 100644 index 000000000..d86924c04 --- /dev/null +++ b/__tests__/server/fixtures/nwac-forecast-active.json @@ -0,0 +1,175 @@ +{ + "id": 184213, + "published_time": "2026-04-01T01:30:00+00:00", + "expires_time": "2026-04-02T01:30:00+00:00", + "created_at": "2026-03-31T23:49:34+00:00", + "updated_at": "2026-04-01T01:05:00+00:00", + "author": "Lee Lazzara", + "product_type": "forecast", + "bottom_line": "

Avalanche danger will rise through the day as a storm moves in and temperatures warm up. Shift to simpler, lower-angle terrain during stormy periods or if you see signs of instability like shooting cracks, rollerballs, or fresh avalanches.<\/p>", + "hazard_discussion": "

West North has been the outlier forecast zone over the last few days, with more snow and avalanche activity over the weekend and into Monday than anyplace else in the Cascades. Precipitation amounts haven't been especially noteworthy, nor have avalanches been very large, but the 4-10 inches of new snow just haven't bonded well to the crust layer just below it. This weak interface produced numerous loose snow avalanches and some slabs as well. The snowpack beneath recent snow is a mix of crusts with rounded grains and soft melt forms—a product of repeated swings between cold, snowy weather and warmer, rainy periods this winter.<\/p>\n

The most important factor heading into the next storm is the current snow surface. Light-density surface snow has faceted, with weak snow lingering even on sunnier aspects where you’d typically expect to only find melt-freeze crusts. A few days of faceting alone isn’t a classic setup for a persistent weak layer, but it is enough to increase sensitivity. Expect fresh slabs to be more reactive than usual, even with relatively moderate amounts of new snow.<\/p>", + "weather_discussion": null, + "announcement": null, + "status": "published", + "media": [ + { + "id": 1, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_5066_69cad8ec14366-large.jpeg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_5066_69cad8ec14366-medium.jpeg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_5066_69cad8ec14366.jpeg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_5066_69cad8ec14366-thumbnail.jpeg" + }, + "type": "image", + "title": null, + "caption": "(3\/30\/2026)

Small soft slabs and sluffs  in very steep areas with a human trigger. They ran on the crust beneath the  6 inches or so of snow that fell in the previous days. <\/p> Photo: Jess Trimble ", + "favorite": true + }, + { + "id": 2, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/62F8E33D-42B0-4CEC-A90C-5CCC48325860_69cb1d1339a7c-large.heic", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/62F8E33D-42B0-4CEC-A90C-5CCC48325860_69cb1d1339a7c-medium.heic", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/62F8E33D-42B0-4CEC-A90C-5CCC48325860_69cb1d1339a7c.heic", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/62F8E33D-42B0-4CEC-A90C-5CCC48325860_69cb1d1339a7c-thumbnail.heic" + }, + "type": "image", + "title": "Public Observation: Swift Creek\/Annette", + "caption": "

(3\/30\/2026)<\/p>\n

A human-triggered slab on the northeast side of Huntoon Point at 5100 feet.<\/p>\n

Photo: Conner Niemann<\/p>", + "favorite": false + }, + { + "id": 3, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Pit%20Mar%2029_69c9d0df0eaf8-large.jpg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Pit%20Mar%2029_69c9d0df0eaf8-medium.jpg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Pit%20Mar%2029_69c9d0df0eaf8.jpg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Pit%20Mar%2029_69c9d0df0eaf8-thumbnail.jpg" + }, + "type": "image", + "title": null, + "caption": "(3\/29\/2026) Photo: Lee Lazzara", + "favorite": false + }, + { + "id": 4, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Shuksan%20Mar%2026_69c5b9f0d8853-large.JPG", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Shuksan%20Mar%2026_69c5b9f0d8853-medium.JPG", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Shuksan%20Mar%2026_69c5b9f0d8853.JPG", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Shuksan%20Mar%2026_69c5b9f0d8853-thumbnail.JPG" + }, + "type": "image", + "title": null, + "caption": "(3\/26\/2026)

A natural D3 slab avalanche on the North Face of Shuksan. With the crown slightly filled in, this likely released on March 25th during a period of heavy snowfall and strong winds. Northerly aspect at roughly 7500 feet.<\/p>\n

(HS-N-D3-U)<\/p> Photo: Lee Lazzara", + "favorite": false + } + ], + "weather_data": null, + "json_data": null, + "avalanche_center": { + "id": "NWAC", + "name": "Northwest Avalanche Center", + "url": "https:\/\/www.nwac.us\/", + "city": "Seattle", + "state": "WA" + }, + "forecast_avalanche_problems": [ + { + "id": 2986878, + "forecast_id": 184213, + "avalanche_problem_id": 2, + "rank": 1, + "likelihood": "likely", + "discussion": "

The incoming storm isn’t arriving cleanly—expect variable snowfall rates, fluctuating snow levels, and shifting winds. Avalanche danger could rise quickly in the afternoon or evening, especially during heavier snowfall, but shallow, reactive slabs may exist from the start. Conditions will vary widely across the zone, with more precipitation near the Baker Backcountry and less along the Canadian border and in North Cascades National Park.<\/p>\n

You are more likely to trigger a slab on steep slopes that are loading up with wind-drifted snow. But with weak, faceted snow or crusts at the current snow surface, be ready to see shallow slabs in all terrain, wind-loaded or not. Track conditions through the day. As snowfall, wind, and poor visibility increase, step back from bigger, steeper, and more complex terrain.<\/p>", + "media": { + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Dec%2016%20small%20crown_6760c96a23f73-large.jpg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Dec%2016%20small%20crown_6760c96a23f73-medium.jpg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Dec%2016%20small%20crown_6760c96a23f73.jpg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Dec%2016%20small%20crown_6760c96a23f73-thumbnail.jpg" + }, + "type": "image", + "title": null, + "caption": "

(12\/16\/2024)<\/p>\n

You could find the slab in wind-sheltered terrain on Wednesday. Weak snow surfaces mean you might see some reactivity even with modest amounts of new snow.<\/p>\n

Photo: Gregg Oliveri<\/p>" + }, + "location": [ + "north upper", + "northeast upper", + "east upper", + "southeast upper", + "south upper", + "southwest upper", + "west upper", + "northwest upper", + "north middle", + "northeast middle", + "east middle", + "southeast middle", + "south middle", + "southwest middle", + "west middle", + "northwest middle" + ], + "size": ["1", "2"], + "name": "Storm Slab", + "problem_description": "Storm Slab avalanches are the release of a cohesive layer (a slab) of new snow that breaks within new snow or on the old snow surface. Storm-slabs typically last between a few hours and few days (following snowfall). Storm-slabs that form over a persistent weak layer (surface hoar, depth hoar, or near-surface facets) may be termed Persistent Slabs or may develop into Persistent Slabs.", + "icon": "http:\/\/api.avalanche.org\/img\/avalanche_problems\/StormSlab.png" + }, + { + "id": 2986879, + "forecast_id": 184213, + "avalanche_problem_id": 6, + "rank": 2, + "likelihood": "possible", + "discussion": "

Wet loose avalanches should be on your radar at low and middle elevations as rising snow levels spit rain onto a bit of new snow. While all should be small, they can still pose a real hazard in rugged, marginally snow-covered terrain or where open creeks exist in the runout. Avoid travel on or directly beneath steep slopes where you see fresh rollerballs, pinwheels, or small wet avalanches.<\/p>", + "media": { + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Pond%20Jan%2020%202024_65ac6bff2dbf4-large.jpg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Pond%20Jan%2020%202024_65ac6bff2dbf4-medium.jpg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Pond%20Jan%2020%202024_65ac6bff2dbf4.jpg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Pond%20Jan%2020%202024_65ac6bff2dbf4-thumbnail.jpg" + }, + "type": "image", + "title": null, + "caption": "

(1\/20\/2024)<\/p>\n

Rain on new snow is a near-guarantee of snow sliding down the hill.<\/p>\n

Photo: Julz Holder - Grounding Truth<\/p>" + }, + "location": [ + "north middle", + "northeast middle", + "east middle", + "southeast middle", + "south middle", + "southwest middle", + "west middle", + "northwest middle", + "north lower", + "northeast lower", + "east lower", + "southeast lower", + "south lower", + "southwest lower", + "west lower", + "northwest lower" + ], + "size": ["1", "1.5"], + "name": "Wet Loose", + "problem_description": "Wet Loose avalanches are the release of wet unconsolidated snow or slush. These avalanches typically occur within layers of wet snow near the surface of the snowpack, but they may quickly gouge into lower snowpack layers. Like Loose Dry Avalanches, they start at a point and entrain snow as they move downhill, forming a fan-shaped avalanche. Other names for loose-wet avalanches include point-release avalanches or sluffs. Loose Wet avalanches can trigger slab avalanches that break into deeper snow layers.", + "icon": "http:\/\/api.avalanche.org\/img\/avalanche_problems\/WetLoose.png" + } + ], + "danger": [ + { "lower": 2, "upper": 3, "middle": 2, "valid_day": "current" }, + { "lower": 1, "upper": 2, "middle": 2, "valid_day": "tomorrow" } + ], + "forecast_zone": [ + { + "id": 1646, + "name": "West Slopes North", + "url": "http:\/\/www.nwac.us\/avalanche-forecast\/#\/west-slopes-north", + "state": "WA", + "zone_id": "4", + "config": null + } + ] +} diff --git a/__tests__/server/fixtures/nwac-forecast.json b/__tests__/server/fixtures/nwac-forecast.json new file mode 100644 index 000000000..f034860b6 --- /dev/null +++ b/__tests__/server/fixtures/nwac-forecast.json @@ -0,0 +1,36 @@ +{ + "id": 184066, + "published_time": "2026-03-30T01:30:00+00:00", + "expires_time": "2026-04-20T01:30:00+00:00", + "created_at": "2026-03-29T23:12:44+00:00", + "updated_at": "2026-03-29T23:35:19+00:00", + "author": "Sam Clairmont", + "product_type": "summary", + "bottom_line": null, + "hazard_discussion": "

Avalanche forecasts have ended for the 2025-26 season in the Olympic Mountains. Daily afternoon Mountain Weather Forecasts<\/a> will continue through early May. <\/strong><\/p>\n

Longer days and the potential for more sunshine make the mountains an inviting place in the spring. However, avalanche accidents and fatalities can occur on snow-covered mountains in April<\/a>. Make sure you have the experience and skills to evaluate avalanche terrain, snowpack conditions, and weather observations to decide when, where, and how to travel in the mountains.<\/p>\n

During winter-like storms: <\/strong><\/p>\n

    \n
  • Winter-like slab avalanches can occur during and immediately following storms. <\/li>\n
  • Avoid slopes greater than 35 degrees when you find more than 6-8” of new snow, during times of heavy snowfall, and when you see wind drifting snow.<\/li>\n
  • New snow changes quickly as it warms up. Danger may increase rapidly when sunny weather or warmer temperatures follow a storm, and result in natural avalanches.<\/li>\n
  • Limit your exposure to areas where avalanches can run and stop during these big swings in the weather. <\/li>\n<\/ul>\n

    During periods of cold clear nights and warm sunny days:<\/strong><\/p>\n

      \n
    • Avalanche danger is often lowest in the morning.<\/li>\n
    • Plan your day to travel and return early<\/li>\n
    • Wet snow avalanches may occur with daytime warming and sunshine.<\/li>\n
    • If you see recent avalanches or new rollerballs avoid similar slopes. <\/li>\n
    • Very large and destructive wet slab avalanches may fail on significant crusts or weak layers.<\/li>\n<\/ul>\n

      Other springtime hazards: <\/strong><\/p>\n

        \n
      • Exposed ground, including dirt, rocks, tree limbs, etc. can become submerged by fresh snowfall, creating typical “early season” travel hazards in April, particularly on southerly aspects and near exposed ridges.<\/li>\n
      • Glide cracks can form on steep smooth slopes as water reaches the ground. These slopes can release without warning at any time of the day or night.<\/li>\n
      • Creeks are opening and snow bridges are weakening. Fatalities involving open creek holes or collapsed snow bridges can occur.<\/li>\n
      • Cornices will sag and can fail during periods of warmer weather. Falling cornices can entrain significant amounts of snow or trigger other avalanches. <\/li>\n
      • Choose routes that help limit the amount of time you spend on, near, and under any of these springtime hazards. <\/li>\n<\/ul>\n

        Even though avalanche forecasting has ended for the season, NWAC will continue to track snow and weather conditions. Large spring storms and\/or widespread avalanche cycles may cause us to issue special avalanche bulletins, avalanche warnings, or other spring statements to keep you informed.<\/p>\n

        You can help your backcountry community by continuing to submit observations on our public site<\/a>. <\/p>\n

        Have a fun and safe spring playing in the mountains!<\/p>\n

        Contact NWAC
        <\/strong>You can reach us at the forecast desk daily from 8 AM to 4 PM:
        Email: 
        forecasters@nwac.us<\/a> 
        Phone: 206-526-6164 (Office voicemail)
        Mail: Northwest Avalanche Center
                   7600 Sandpoint Way NE
                   Seattle, Washington 98115<\/p>", + "weather_discussion": null, + "announcement": null, + "status": "published", + "media": [], + "weather_data": null, + "json_data": null, + "avalanche_center": { + "id": "NWAC", + "name": "Northwest Avalanche Center", + "url": "https:\/\/www.nwac.us\/", + "city": "Seattle", + "state": "WA" + }, + "forecast_avalanche_problems": [], + "danger": [], + "forecast_zone": [ + { + "id": 1645, + "name": "Olympics", + "url": "http:\/\/www.nwac.us\/avalanche-forecast\/#\/olympics", + "state": "WA", + "zone_id": "1", + "config": null + } + ] +} diff --git a/__tests__/server/fixtures/nwac-warning.json b/__tests__/server/fixtures/nwac-warning.json new file mode 100644 index 000000000..fc2f62a5b --- /dev/null +++ b/__tests__/server/fixtures/nwac-warning.json @@ -0,0 +1,7 @@ +{ + "avalanche_center": null, + "published_time": null, + "expires_time": null, + "created_at": null, + "updated_at": null +} diff --git a/__tests__/server/fixtures/sac-forecast.json b/__tests__/server/fixtures/sac-forecast.json new file mode 100644 index 000000000..c90808ce7 --- /dev/null +++ b/__tests__/server/fixtures/sac-forecast.json @@ -0,0 +1,246 @@ +{ + "id": 184250, + "published_time": "2026-04-01T13:44:00+00:00", + "expires_time": "2026-04-02T11:00:00+00:00", + "created_at": "2026-04-01T12:52:14+00:00", + "updated_at": "2026-04-01T13:44:13+00:00", + "author": "Brandon Schwartz", + "product_type": "forecast", + "bottom_line": "

        Backcountry travelers could easily trigger large avalanches today, especially in locations with 6 or more inches of new snow. Consider avoiding avalanche terrain in areas where clues to unstable snow are present, such as slopes filling in with wind drifted snow, snow surface cracking, or recent avalanches.<\/p>", + "hazard_discussion": "", + "weather_discussion": null, + "announcement": null, + "status": "published", + "media": [ + { + "id": 1, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/barely-covered-rocks_69cc181752af2-large.jpg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/barely-covered-rocks_69cc181752af2-medium.jpg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/barely-covered-rocks_69cc181752af2.jpg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/barely-covered-rocks_69cc181752af2-thumbnail.jpg" + }, + "type": "image", + "title": null, + "caption": "(3\/31\/2026)

        About an inch of new snow on the summit of Andesite Peak. Many rocks and other obstacles remain exposed. Now they just have a dusting of new snow. <\/p>\n

        Andesite Peak, Donner Summit are, CA<\/p> Photo: Sierra Avalanche Center", + "favorite": false + }, + { + "id": 2, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260328_171835661_69c82b932c2ad.MP_69c82b932c2ad-large.jpg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260328_171835661_69c82b932c2ad.MP_69c82b932c2ad-medium.jpg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260328_171835661_69c82b932c2ad.MP_69c82b932c2ad.jpg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260328_171835661_69c82b932c2ad.MP_69c82b932c2ad-thumbnail.jpg" + }, + "type": "image", + "title": null, + "caption": "(3\/28\/2026)

        More exposed rocks and other obstacles continue to melt out each day.<\/p>\n

        Summit of Andesite Peak.<\/p> Photo: Sierra Avalanche Center", + "favorite": false + }, + { + "id": 3, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260329_203809986_69c9a825deb43.MP_69c9a825deb43-large.jpg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260329_203809986_69c9a825deb43.MP_69c9a825deb43-medium.jpg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260329_203809986_69c9a825deb43.MP_69c9a825deb43.jpg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260329_203809986_69c9a825deb43.MP_69c9a825deb43-thumbnail.jpg" + }, + "type": "image", + "title": null, + "caption": "(3\/29\/2026)

        N-NE-E aspects like the Hourglass on Tamarack Peak still have decent snow coverage while S aspects like those on Mt. Rose are mostly dirt. <\/p> Photo: Sierra Avalanche Center", + "favorite": false + }, + { + "id": 4, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_1021_69c8a4f29153c-large.jpeg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_1021_69c8a4f29153c-medium.jpeg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_1021_69c8a4f29153c.jpeg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_1021_69c8a4f29153c-thumbnail.jpeg" + }, + "type": "image", + "title": "Looking west", + "caption": "(3\/28\/2026)

        Looking west towards Round Top. <\/p> Photo: Mark Speicher", + "favorite": false + }, + { + "id": 5, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_1024_69c8a4f3c0539-large.jpeg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_1024_69c8a4f3c0539-medium.jpeg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_1024_69c8a4f3c0539.jpeg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_1024_69c8a4f3c0539-thumbnail.jpeg" + }, + "type": "image", + "title": "Red Lake Peak", + "caption": "(3\/28\/2026)

        Red Lake Peak in the distance.  South faces have little to no snow left. <\/p> Photo: Mark Speicher", + "favorite": false + }, + { + "id": 6, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_0989_69c8a4efa5b7b-large.jpeg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_0989_69c8a4efa5b7b-medium.jpeg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_0989_69c8a4efa5b7b.jpeg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_0989_69c8a4efa5b7b-thumbnail.jpeg" + }, + "type": "image", + "title": "Forestdale Rd. ", + "caption": "(3\/28\/2026)

        Forestdale Rd is 60% covered with snow to the bridge. Trucks have not yet driven down the road.  It is still managable for a snowmobile. <\/p> Photo: Mark Speicher", + "favorite": false + }, + { + "id": 7, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260328_170213668_69c82b94abaaa-large.jpg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260328_170213668_69c82b94abaaa-medium.jpg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260328_170213668_69c82b94abaaa.jpg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260328_170213668_69c82b94abaaa-thumbnail.jpg" + }, + "type": "image", + "title": null, + "caption": "(3\/28\/2026)

        Snowmelt continues to accelerate on E aspects as well leaving patchy snow coverage and lots of exposed debris.<\/p>\n

        Andesite Peak<\/p> Photo: Sierra Avalanche Center", + "favorite": false + }, + { + "id": 8, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Obs%20Photos%281%29_69c6fc5d4d10d-large.jpg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Obs%20Photos%281%29_69c6fc5d4d10d-medium.jpg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Obs%20Photos%281%29_69c6fc5d4d10d.jpg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/Obs%20Photos%281%29_69c6fc5d4d10d-thumbnail.jpg" + }, + "type": "image", + "title": null, + "caption": "(3\/27\/2026)

        More rocks and other obstacles are starting to melt out on northerly aspects, but they still have decent coverage, especially above 9000 ft. Much less snow and much more bare ground exists on the sunnier south through west aspects. <\/p> Photo: Sierra Avalanche Center", + "favorite": false + }, + { + "id": 9, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260327_165133946_69c6fc5fd1e76.MP_69c6fc5fd1e76-large.jpg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260327_165133946_69c6fc5fd1e76.MP_69c6fc5fd1e76-medium.jpg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260327_165133946_69c6fc5fd1e76.MP_69c6fc5fd1e76.jpg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260327_165133946_69c6fc5fd1e76.MP_69c6fc5fd1e76-thumbnail.jpg" + }, + "type": "image", + "title": null, + "caption": "(3\/27\/2026)

        What was on the snow travel on March 25 is now dirt in some places on the approach to Mt. Houghton.<\/p> Photo: Sierra Avalanche Center", + "favorite": false + }, + { + "id": 10, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_4793_69c44618761ae-large.jpeg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_4793_69c44618761ae-medium.jpeg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_4793_69c44618761ae.jpeg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_4793_69c44618761ae-thumbnail.jpeg" + }, + "type": "image", + "title": null, + "caption": "(3\/25\/2026)

        Walking the Rubicon summer trail from 6900' to 7600' on most all dirt trail.<\/p>\n

        Rubicon Peak, West Shore.<\/p> Photo: Sierra Avalanche Center", + "favorite": false + }, + { + "id": 11, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_4806_69c5a98a07f65-large.jpeg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_4806_69c5a98a07f65-medium.jpeg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_4806_69c5a98a07f65.jpeg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_4806_69c5a98a07f65-thumbnail.jpeg" + }, + "type": "image", + "title": null, + "caption": "(3\/26\/2026)

        NE\/E face of Elephants Back.<\/p>\n

        Carson Pass area.<\/p> Photo: Sierra Avalanche Center", + "favorite": false + }, + { + "id": 12, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260327_173243667_69c6fc60061d7.MP_69c6fc60061d7-large.jpg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260327_173243667_69c6fc60061d7.MP_69c6fc60061d7-medium.jpg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260327_173243667_69c6fc60061d7.MP_69c6fc60061d7.jpg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/PXL_20260327_173243667_69c6fc60061d7.MP_69c6fc60061d7-thumbnail.jpg" + }, + "type": "image", + "title": null, + "caption": "(3\/27\/2026)

        Mount Houghton's SE gullies have lost significant snow since March 19th. <\/p> Photo: Sierra Avalanche Center", + "favorite": false + } + ], + "weather_data": { "weather_product_id": 184245 }, + "json_data": null, + "avalanche_center": { + "id": "SAC", + "name": "Sierra Avalanche Center", + "url": "https:\/\/www.sierraavalanchecenter.org\/", + "city": "Truckee", + "state": "CA" + }, + "forecast_avalanche_problems": [ + { + "id": 2988958, + "forecast_id": 184250, + "avalanche_problem_id": 2, + "rank": 1, + "likelihood": "likely", + "discussion": "

        Avalanche hazard is expected to vary significantly today from one location to another around the forecast area due to a very wide range in new snow accumulation both over the past 24 hours and during the day today. Consider avoiding avalanche terrain today, especially in locations with 6 or more inches of new snow, where slopes are filling in with wind drifted snow, where you encounter snow surface cracking, or see signs of recent avalanches. A firm crust at the old\/new snow interface may act as an efficient bed surface and allow for some long running avalanches today.<\/p>\n

        As new snow continues to accumulate today, expect to encounter areas of unstable snow in both wind exposed and wind protected terrain. Wind speeds are forecast to reach gale force\/extreme speeds this afternoon. This increase in wind speeds is expected to increase the amount of wind loading that occurs in near treeline and below treeline areas as the day progresses. There is also the chance of some dry or wet loose snow avalanches today, where new snow slides on firm crust at the old\/new snow interface.<\/p>\n

        Before this storm, significant snowmelt occurred across the forecast area. Large areas of bare ground were present on all aspects below 7,500' to 8,000' as well as on southerly aspects at all elevations. Today's avalanche concerns are focused on locations where new snow has fallen on top of old snow. Where new snow has fallen on bare ground, avalanche concerns are minimal. In all areas, numerous hazards such as rocks, stumps, logs, etc are barely hidden under the recent new snow. Anticipate conditions similar to early season, where it is very easy to hit hard objects under the snow surface as you travel.<\/p>", + "media": { + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_6111_698d36bd970cc-large.jpeg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_6111_698d36bd970cc-medium.jpeg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_6111_698d36bd970cc.jpeg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_6111_698d36bd970cc-thumbnail.jpeg" + }, + "type": "image", + "title": null, + "caption": "

        Snow surface cracking like this indicates that you have found unstable snow. Where this is the case, consider opting for slopes with a slope angle under 30 degrees until you have navigated away from avalanche terrain.<\/p>\n

        This photo was taken earlier this year near Blue Lakes, CA.<\/p>\n

        Photo: Jason Bilek<\/p>" + }, + "location": [ + "northwest middle", + "southeast middle", + "southeast upper", + "northwest upper", + "north upper", + "north middle", + "northeast middle", + "northeast upper", + "east upper", + "east middle", + "south upper", + "southwest upper", + "west upper", + "south middle", + "southwest middle", + "west middle", + "north lower", + "northeast lower", + "east lower", + "southeast lower", + "south lower", + "southwest lower", + "west lower", + "northwest lower" + ], + "size": ["1", "2"], + "name": "Storm Slab", + "problem_description": "Storm Slab avalanches are the release of a cohesive layer (a slab) of new snow that breaks within new snow or on the old snow surface. Storm-slabs typically last between a few hours and few days (following snowfall). Storm-slabs that form over a persistent weak layer (surface hoar, depth hoar, or near-surface facets) may be termed Persistent Slabs or may develop into Persistent Slabs.", + "icon": "http:\/\/api.avalanche.org\/img\/avalanche_problems\/StormSlab.png" + } + ], + "danger": [ + { "lower": 3, "upper": 3, "middle": 3, "valid_day": "current" }, + { "lower": 3, "upper": 3, "middle": 3, "valid_day": "tomorrow" } + ], + "forecast_zone": [ + { + "id": 2458, + "name": "Central Sierra Nevada", + "url": "https:\/\/www.sierraavalanchecenter.org\/forecasts\/avalanche\/central-sierra-nevada#\/central-sierra-nevada\/", + "state": "CA", + "zone_id": "1", + "config": null + } + ] +} diff --git a/__tests__/server/fixtures/sac-warning.json b/__tests__/server/fixtures/sac-warning.json new file mode 100644 index 000000000..fc2f62a5b --- /dev/null +++ b/__tests__/server/fixtures/sac-warning.json @@ -0,0 +1,7 @@ +{ + "avalanche_center": null, + "published_time": null, + "expires_time": null, + "created_at": null, + "updated_at": null +} diff --git a/__tests__/server/fixtures/snfac-forecast.json b/__tests__/server/fixtures/snfac-forecast.json new file mode 100644 index 000000000..0b1e4a08c --- /dev/null +++ b/__tests__/server/fixtures/snfac-forecast.json @@ -0,0 +1,142 @@ +{ + "id": 184224, + "published_time": "2026-04-01T12:56:00+00:00", + "expires_time": "2026-04-02T10:00:00+00:00", + "created_at": "2026-04-01T10:54:07+00:00", + "updated_at": "2026-04-01T13:36:05+00:00", + "author": "Zachary Miller", + "product_type": "forecast", + "bottom_line": "

        Watch for and avoid fresh wind drifts and slabs. You will find more, and larger, drifts and slabs as you climb from middle to upper elevations. At lower elevations, steer clear of steep slopes if you are sinking into a wet, mushy snowpack.<\/p>\n

        We will issue our last daily Avalanche Forecast of the season this Sunday, April 5th. We will issue General Avalanche Information updates through late April or early May.  <\/em><\/p>", + "hazard_discussion": "

        Just when we thought winter had punched its timecard and clocked out - April Fools - it's back! With the return of snowfall, it's worth turning our brains back into winter mode and out of the simplified spring-diurnal mindset. Keep your head on a swivel for changing conditions if you're out enjoying the cold new snow, especially in areas where you find more than 6\" of fresh snow. Spring storms are notoriously spotty in character, with large amounts of variability in the timing and quantity of snowfall across the landscape. Additionally, convective storms could produce graupel or other snow crystal types that form sensitive mid-storm interfaces. These interfaces make great failure layers for even thin wind slabs that quickly form above them.<\/p>\n

        Conveniently, the recent warm weather has solidified much of the snowpack below this new snowfall. On steep slopes where new snow is accumulating on firm, hard ice or wind-hammered surfaces, loose snow avalanches (sluffs)<\/a> could release naturally or be easy to trigger and run faster and further than expected. At lower elevations near or below the rain-snow line, wet avalanches are still in the mix, and avoiding steep slopes where you find unsupportable wet snow is a great way to play it safe. Given the variety of conditions you may experience in the mountains today, be ready to pull the plug and switch to a more conservative plan B if you see obvious signs of instability or feel the hairs on the back of your neck stand up.<\/p>", + "weather_discussion": null, + "announcement": null, + "status": "published", + "media": [ + { + "id": 1, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_4074_69cc69b3bdd4e-large.jpeg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_4074_69cc69b3bdd4e-medium.jpeg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_4074_69cc69b3bdd4e.jpeg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_4074_69cc69b3bdd4e-thumbnail.jpeg" + }, + "type": "image", + "title": "Public Observation: Bench Hut", + "caption": "(3\/31\/2026)

        Wet loose off the Defibrillator couloir (north facing) observed on 3\/31\/2026 in the Sawtooth Range.<\/p> Photo: Jon Preuss", + "favorite": false + }, + { + "id": 2, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/260326_PoleCk_001_69c5da4df1f15-large.jpeg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/260326_PoleCk_001_69c5da4df1f15-medium.jpeg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/260326_PoleCk_001_69c5da4df1f15.jpeg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/260326_PoleCk_001_69c5da4df1f15-thumbnail.jpeg" + }, + "type": "image", + "title": "Pairie Creek wet slab avalanche", + "caption": "

        (3\/26\/2026) Wet slab and wet loose avalanches. E-SE @ 9,300-9,800'. Prairie Ck. <\/p>\n

        Photo: Sawtooth Avalanche Center<\/p>", + "favorite": false + }, + { + "id": 3, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/AAE23B67-7543-4662-83C7-00A55C80157E_69c1f02a1784d-large.jpg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/AAE23B67-7543-4662-83C7-00A55C80157E_69c1f02a1784d-medium.jpg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/AAE23B67-7543-4662-83C7-00A55C80157E_69c1f02a1784d.jpg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/AAE23B67-7543-4662-83C7-00A55C80157E_69c1f02a1784d-thumbnail.jpg" + }, + "type": "image", + "title": "Public Observation: Marshall Lakes Basin", + "caption": "

        (3\/23\/2026) Wet Loose avalanche observed in Marshall Lakes Basin in the Northern Sawtooth Range.<\/p>\n

        Photo: Bernard, Rich, Seymore, Armstomg, Anderson<\/p>", + "favorite": false + }, + { + "id": 4, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_2728_69c1c99ae8dc1-large.jpeg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_2728_69c1c99ae8dc1-medium.jpeg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_2728_69c1c99ae8dc1.jpeg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/IMG_2728_69c1c99ae8dc1-thumbnail.jpeg" + }, + "type": "image", + "title": "Public Observation: Bench Lakes", + "caption": "

        (3\/22\/2026) Large recent wet loose avalanches on Williams Peak's S face.<\/p>", + "favorite": false + }, + { + "id": 5, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/2603221_BaldyOB_Gatorade_watermovement_labeled_69bf6e52487c9-large.jpg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/2603221_BaldyOB_Gatorade_watermovement_labeled_69bf6e52487c9-medium.jpg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/2603221_BaldyOB_Gatorade_watermovement_labeled_69bf6e52487c9.jpg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/2603221_BaldyOB_Gatorade_watermovement_labeled_69bf6e52487c9-thumbnail.jpg" + }, + "type": "image", + "title": null, + "caption": "

        (3\/21\/2026) This photo was taken a couple of minutes after pouring purple Gatorade onto the snow surface. The layers where water collects, or pools, stand out in the photo, especially the 2 crusts immediately above the faceted snow at\/near the ground. <\/p>\n

        Photo: Sawtooth Avalanche Center<\/p>", + "favorite": false + }, + { + "id": 6, + "url": { + "large": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/P1170364_69bb84f0659d6-large.jpeg", + "medium": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/P1170364_69bb84f0659d6-medium.jpeg", + "original": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/P1170364_69bb84f0659d6.jpeg", + "thumbnail": "https:\/\/avalanche-org-media.s3.us-west-2.amazonaws.com\/P1170364_69bb84f0659d6-thumbnail.jpeg" + }, + "type": "image", + "title": "Peak 9367", + "caption": "

        (3\/18\/2026) Wet loose avalanche ~8500' on a west aspect in the Smiley Creek drainage on Peak 9367.<\/p>\n

        Photo: Sawtooth Avalanche Center<\/p>", + "favorite": false + } + ], + "weather_data": { "weather_product_id": 184222 }, + "json_data": null, + "avalanche_center": { + "id": "SNFAC", + "name": "Sawtooth Avalanche Center", + "url": "https:\/\/www.sawtoothavalanche.com\/", + "city": "Ketchum", + "state": "ID" + }, + "forecast_avalanche_problems": [ + { + "id": 2988903, + "forecast_id": 184224, + "avalanche_problem_id": 3, + "rank": 1, + "likelihood": "possible", + "discussion": "

        You could find wind slabs up to 2 feet thick in exposed terrain. Wind slabs are largest and most widespread at upper elevations.<\/p>\n

        Recent cornice growth, pillows, and dunes<\/a> are all clues that wind was at work moving snow. Look for shooting cracks<\/a> as evidence of fresh wind slabs and a nudge to move to softer snow on more sheltered slopes. These slabs commonly form beneath ridgelines, on the sides of cross-loaded gullies, and lower in cirques below large cliff bands, chutes, and couloirs.<\/p>", + "media": { "url": "", "type": "", "caption": "" }, + "location": [ + "north upper", + "northeast upper", + "east upper", + "southeast upper", + "south upper", + "southwest upper", + "west upper", + "northwest upper" + ], + "size": ["1", "2"], + "name": "Wind Slab", + "problem_description": "Wind Slab avalanches are the release of a cohesive layer of snow (a slab) formed by the wind. Wind typically transports snow from the upwind sides of terrain features and deposits snow on the downwind side. Wind slabs are often smooth and rounded and sometimes sound hollow, and can range from soft to hard. Wind slabs that form over a persistent weak layer (surface hoar, depth hoar, or near-surface facets) may be termed Persistent Slabs or may develop into Persistent Slabs.", + "icon": "http:\/\/api.avalanche.org\/img\/avalanche_problems\/WindSlab.png" + } + ], + "danger": [ + { "lower": 2, "upper": 2, "middle": 2, "valid_day": "current" }, + { "lower": 2, "upper": 3, "middle": 2, "valid_day": "tomorrow" } + ], + "forecast_zone": [ + { + "id": 2906, + "name": "Sawtooth & Western Smoky Mtns", + "url": "https:\/\/www.sawtoothavalanche.com\/forecasts\/avalanche\/sawtooth-&-western-smoky-mtns", + "state": "ID", + "zone_id": "2", + "config": null + } + ] +} diff --git a/__tests__/server/fixtures/snfac-warning.json b/__tests__/server/fixtures/snfac-warning.json new file mode 100644 index 000000000..fc2f62a5b --- /dev/null +++ b/__tests__/server/fixtures/snfac-warning.json @@ -0,0 +1,7 @@ +{ + "avalanche_center": null, + "published_time": null, + "expires_time": null, + "created_at": null, + "updated_at": null +} diff --git a/__tests__/server/forecastSchemas.server.test.ts b/__tests__/server/forecastSchemas.server.test.ts new file mode 100644 index 000000000..56872b725 --- /dev/null +++ b/__tests__/server/forecastSchemas.server.test.ts @@ -0,0 +1,166 @@ +import { + DangerLevel, + ForecastPeriod, + forecastResultSchema, + mediaItemSchema, + MediaType, + ProductType, + warningResultSchema, +} from '@/services/nac/types/forecastSchemas' +import nwacForecastActive from './fixtures/nwac-forecast-active.json' +import nwacForecastSummary from './fixtures/nwac-forecast.json' +import nwacWarning from './fixtures/nwac-warning.json' +import sacForecast from './fixtures/sac-forecast.json' +import sacWarning from './fixtures/sac-warning.json' +import snfacForecast from './fixtures/snfac-forecast.json' +import snfacWarning from './fixtures/snfac-warning.json' + +describe('forecastResultSchema', () => { + it('parses an active NWAC forecast with string-typed sizes', () => { + const result = forecastResultSchema.parse(nwacForecastActive) + expect(result.product_type).toBe(ProductType.Forecast) + if (result.product_type === ProductType.Forecast) { + expect(result.forecast_avalanche_problems.length).toBeGreaterThan(0) + // NWAC returns string sizes — verify they are transformed to numbers + for (const problem of result.forecast_avalanche_problems) { + for (const size of problem.size) { + expect(typeof size).toBe('number') + } + } + expect(result.danger.length).toBeGreaterThan(0) + expect(result.danger[0].valid_day).toBe(ForecastPeriod.Current) + } + }) + + it('parses an SAC forecast with string-typed sizes', () => { + const result = forecastResultSchema.parse(sacForecast) + expect(result.product_type).toBe(ProductType.Forecast) + if (result.product_type === ProductType.Forecast) { + expect(result.forecast_avalanche_problems.length).toBeGreaterThan(0) + for (const problem of result.forecast_avalanche_problems) { + for (const size of problem.size) { + expect(typeof size).toBe('number') + } + } + } + }) + + it('parses an SNFAC forecast', () => { + const result = forecastResultSchema.parse(snfacForecast) + expect(result.product_type).toBe(ProductType.Forecast) + if (result.product_type === ProductType.Forecast) { + expect(result.forecast_avalanche_problems.length).toBeGreaterThan(0) + expect(result.avalanche_center.id).toBe('SNFAC') + } + }) + + it('parses an off-season NWAC summary product', () => { + const result = forecastResultSchema.parse(nwacForecastSummary) + expect(result.product_type).toBe(ProductType.Summary) + // Summary products don't have danger or problems in their type + expect(result.avalanche_center.id).toBe('NWAC') + }) + + it('preserves null danger levels as DangerLevel.None', () => { + const result = forecastResultSchema.parse(nwacForecastActive) + if (result.product_type === ProductType.Forecast) { + for (const danger of result.danger) { + expect(danger.lower).toBeGreaterThanOrEqual(DangerLevel.None) + expect(danger.middle).toBeGreaterThanOrEqual(DangerLevel.None) + expect(danger.upper).toBeGreaterThanOrEqual(DangerLevel.None) + } + } + }) + + it('parses forecast media items', () => { + const result = forecastResultSchema.parse(sacForecast) + if (result.product_type === ProductType.Forecast && result.media) { + expect(result.media.length).toBeGreaterThan(0) + for (const item of result.media) { + expect(item.type).not.toBe(MediaType.Unknown) + } + } + }) +}) + +describe('warningResultSchema', () => { + it('parses a null NWAC warning (no active warning)', () => { + const result = warningResultSchema.parse(nwacWarning) + expect(result.avalanche_center).toBeNull() + expect(result.published_time).toBeNull() + }) + + it('parses a null SAC warning', () => { + const result = warningResultSchema.parse(sacWarning) + expect(result.avalanche_center).toBeNull() + }) + + it('parses a null SNFAC warning', () => { + const result = warningResultSchema.parse(snfacWarning) + expect(result.avalanche_center).toBeNull() + }) +}) + +describe('mediaItemSchema', () => { + it('parses an image media item', () => { + const item = { + id: 123, + type: 'image', + url: { + large: 'https://example.com/large.jpg', + medium: 'https://example.com/medium.jpg', + original: 'https://example.com/original.jpg', + thumbnail: 'https://example.com/thumb.jpg', + }, + caption: 'Test caption', + } + const result = mediaItemSchema.parse(item) + expect(result.type).toBe(MediaType.Image) + }) + + it('parses a video media item with external link', () => { + const item = { + id: 456, + type: 'video', + url: { + external_link: 'https://youtube.com/watch?v=abc', + external_type: 'video', + }, + caption: 'Video caption', + } + const result = mediaItemSchema.parse(item) + expect(result.type).toBe(MediaType.Video) + }) + + it('parses a PDF media item', () => { + const item = { + type: 'pdf', + url: { + original: 'https://example.com/doc.pdf', + }, + } + const result = mediaItemSchema.parse(item) + expect(result.type).toBe(MediaType.PDF) + }) + + it('falls back to unknown for unrecognized media types', () => { + const item = { + type: 'hologram', + url: 'https://example.com/holo', + } + const result = mediaItemSchema.parse(item) + expect(result.type).toBe(MediaType.Unknown) + }) + + it('parses an empty media item', () => { + const item = { type: '', url: '', caption: '' } + const result = mediaItemSchema.parse(item) + expect(result.type).toBe(MediaType.None) + }) + + it('parses a null media item', () => { + const item = { type: null, url: null, caption: '', title: '' } + const result = mediaItemSchema.parse(item) + expect(result.type).toBeNull() + }) +}) diff --git a/__tests__/server/resolveZone.server.test.ts b/__tests__/server/resolveZone.server.test.ts new file mode 100644 index 000000000..39b5360af --- /dev/null +++ b/__tests__/server/resolveZone.server.test.ts @@ -0,0 +1,96 @@ +import type { ActiveForecastZoneWithSlug } from '../../src/services/nac/nac' +import { AvalancheForecastZoneStatus } from '../../src/services/nac/types/schemas' + +const mockGetActiveForecastZones = jest.fn() + +jest.mock('../../src/services/nac/nac', () => ({ + getActiveForecastZones: (...args: unknown[]) => mockGetActiveForecastZones(...args), +})) + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { resolveZoneFromSlug } = require('../../src/services/nac/resolveZone') + +const mockZoneConfig = { + elevation_band_names: { + lower: 'Below Treeline', + middle: 'Near Treeline', + upper: 'Above Treeline', + }, +} + +const mockZones: ActiveForecastZoneWithSlug[] = [ + { + slug: 'west-slopes-north', + zone: { + status: AvalancheForecastZoneStatus.Active, + id: 1001, + name: 'West Slopes North', + url: '/forecasts/west-slopes-north', + rank: 1, + zone_id: 'WSN', + config: mockZoneConfig, + }, + }, + { + slug: 'east-slopes-north', + zone: { + status: AvalancheForecastZoneStatus.Active, + id: 1002, + name: 'East Slopes North', + url: '/forecasts/east-slopes-north', + rank: 2, + zone_id: 'ESN', + config: mockZoneConfig, + }, + }, +] + +beforeEach(() => { + mockGetActiveForecastZones.mockReset() + mockGetActiveForecastZones.mockResolvedValue(mockZones) +}) + +describe('resolveZoneFromSlug', () => { + it('returns the correct zone for a valid slug', async () => { + const result = await resolveZoneFromSlug('nwac', 'west-slopes-north') + + expect(result).not.toBeNull() + expect(result?.slug).toBe('west-slopes-north') + expect(result?.zone.id).toBe(1001) + expect(result?.zone.name).toBe('West Slopes North') + }) + + it('returns null for an unknown slug', async () => { + const result = await resolveZoneFromSlug('nwac', 'nonexistent-zone') + + expect(result).toBeNull() + }) + + it('applies DVAC alias — passes nwac to getActiveForecastZones', async () => { + await resolveZoneFromSlug('dvac', 'west-slopes-north') + + expect(mockGetActiveForecastZones).toHaveBeenCalledWith('nwac') + }) + + it('DVAC alias resolves the correct zone', async () => { + const result = await resolveZoneFromSlug('dvac', 'east-slopes-north') + + expect(result).not.toBeNull() + expect(result?.slug).toBe('east-slopes-north') + expect(result?.zone.id).toBe(1002) + }) + + it('passes through non-dvac center slugs unchanged', async () => { + await resolveZoneFromSlug('sac', 'some-zone') + + expect(mockGetActiveForecastZones).toHaveBeenCalledWith('sac') + }) + + it('returns null when no zones exist for the center', async () => { + mockGetActiveForecastZones.mockResolvedValue([]) + + const result = await resolveZoneFromSlug('nwac', 'west-slopes-north') + + expect(result).toBeNull() + }) +}) diff --git a/__tests__/server/sanitizeHtml.server.test.ts b/__tests__/server/sanitizeHtml.server.test.ts new file mode 100644 index 000000000..cf7d7bac2 --- /dev/null +++ b/__tests__/server/sanitizeHtml.server.test.ts @@ -0,0 +1,27 @@ +import { sanitizeHtml } from '@/components/forecast/sanitizeHtml' + +describe('sanitizeHtml', () => { + it('opens external (other-domain) links in a new tab with a safe rel', () => { + const out = sanitizeHtml('

        See NWAC

        ') + expect(out).toContain('href="https://nwac.us/foo"') + expect(out).toContain('target="_blank"') + expect(out).toContain('rel="noopener noreferrer"') + }) + + it('treats protocol-relative URLs as external', () => { + const out = sanitizeHtml('x') + expect(out).toContain('target="_blank"') + }) + + it('leaves relative links in the same tab (no target)', () => { + const out = sanitizeHtml('zone') + expect(out).not.toContain('target="_blank"') + }) + + it('strips disallowed tags but keeps their text content', () => { + const out = sanitizeHtml('

        Safe text

        ') + expect(out).not.toContain('text
        ') + expect(out).toContain('Safe') + }) +}) diff --git a/docs/nac-data-display.md b/docs/nac-data-display.md new file mode 100644 index 000000000..e889a0a74 --- /dev/null +++ b/docs/nac-data-display.md @@ -0,0 +1,55 @@ +# Displaying NAC Data in Native Pages + +This document captures conventions and known discrepancies for rendering NAC API data (forecasts, warnings, danger ratings) in native Next.js pages rather than the embedded Vue widget. + +## Danger Scale Colors + +The canonical danger colors are consistent across the avy app, afp-public-widgets, and our native implementation: + +| Level | Name | Hex | Source agreement | +|-------|------|-----|------------------| +| 5 | Extreme | `#231f20` | All sources agree | +| 4 | High | `#ed1c24` | All sources agree | +| 3 | Considerable | `#f7941e` | All sources agree | +| 2 | Moderate | `#fff200` | All sources agree | +| 1 | Low | `#50b848` | All sources agree | + +### "No Rating" / Level 0 — Discrepancy + +Level 0 (None) uses different colors in different contexts within afp-public-widgets: + +| Context | Hex | Description | +|---------|-----|-------------| +| `DangerElevation.vue` | `#939598` | Gray, used in elevation band graphics | +| `forecasts.js` constant | `#909092` | Slightly different gray | +| SCSS `$no-rating` | `#6ea4db` | Blue, used in maps and charts | +| `.danger-bg-0` utility | `#ccc` | Light gray, used in generic backgrounds | + +Our native implementation uses `#939598` (matching the elevation display component) for `None` and `#6ea4db` for `GeneralInformation` (-1). This aligns with the avy app's `colorFor()` function. + +**TODO:** Align all implementations on a single "no rating" color per context (elevation graphics vs. maps vs. generic backgrounds), or document the intentional differences. + +### Text Color on Danger Backgrounds + +The afp-public-widgets only apply white text to **Extreme** (level 5). All other levels use dark text, including High (red). Our native implementation follows this convention: + +- Extreme (`#231f20` background) → white text +- All other levels → dark text (`#1a1a1a`) + +The avy app does not have explicit text color logic — it relies on the `color` npm package for contrast calculations, which may produce different results than the widget CSS. + +## Danger Icons + +Self-hosted PNGs at `/images/danger-icons/{0-5}.png`, copied from `avy/assets/danger-icons/`. These are the standard NAC danger scale icons used across all implementations. + +## Problem Type Icons + +Self-hosted PNGs at `/images/problem-icons/{ProblemName}.png`, copied from `avy/assets/problem-icons/`. Available types: CorniceFall, DeepPersistentSlab, DryLoose, Glide, PersistentSlab, StormSlab, WetLoose, WetSlab, WindSlab. + +## Reference: Source Implementations + +When investigating display behavior, these are the authoritative sources: + +- **afp-public-widgets** — The Vue 3 widget we're replacing. Colors in `src/constants/forecasts.js` and `src/css/_variables.scss`. Components in `src/components/forecasts/`. +- **avy** (React Native app) — The mobile app. Danger colors in `components/AvalancheDangerTriangle.tsx` (`colorFor`). Names in `components/helpers/dangerText.ts`. +- **web native** — Our implementation. Utilities in `src/services/nac/dangerScale.ts`. diff --git a/drift.lock b/drift.lock index da1f7af9e..7d0b49044 100644 --- a/drift.lock +++ b/drift.lock @@ -223,7 +223,7 @@ sig = "eef97067a5b42a38" [[bindings]] doc = "docs/decisions/007-dynamic-tenants-middleware.md" target = "src/middleware.ts" -sig = "ff301eba57dc04e2" +sig = "fd722c55fdb3239d" [[bindings]] doc = "docs/decisions/007-dynamic-tenants-middleware.md" @@ -233,7 +233,7 @@ sig = "2fcb46e02fa2f388" [[bindings]] doc = "docs/decisions/008-edge-config-tenant-lookup.md" target = "src/middleware.ts" -sig = "ff301eba57dc04e2" +sig = "fd722c55fdb3239d" [[bindings]] doc = "docs/decisions/008-edge-config-tenant-lookup.md" @@ -288,7 +288,7 @@ sig = "0ce7dd3d375aa0fe" [[bindings]] doc = "docs/decisions/013-hardcoded-tenant-lookup.md" target = "src/middleware.ts" -sig = "ff301eba57dc04e2" +sig = "fd722c55fdb3239d" [[bindings]] doc = "docs/decisions/013-hardcoded-tenant-lookup.md" @@ -333,7 +333,7 @@ sig = "7c11051944f82644" [[bindings]] doc = "docs/decisions/016-per-tenant-globals-as-unique-tenant-collections.md" target = "src/collections/Settings/index.ts" -sig = "3e50277f603c25bb" +sig = "ce23ea8c0093841c" [[bindings]] doc = "docs/decisions/016-per-tenant-globals-as-unique-tenant-collections.md" @@ -443,7 +443,7 @@ sig = "f3230eac010baa8c" [[bindings]] doc = "docs/migration-safety.md" target = "src/migrations/index.ts" -sig = "1c3f2d8cd5058617" +sig = "7c70ed390657b65a" [[bindings]] doc = "docs/migration-safety.md" @@ -598,4 +598,4 @@ sig = "b1d9736649cf9b6a" [[bindings]] doc = "docs/troubleshooting.md" target = "src/middleware.ts" -sig = "ff301eba57dc04e2" +sig = "fd722c55fdb3239d" diff --git a/package.json b/package.json index 5194f0209..e283775bc 100644 --- a/package.json +++ b/package.json @@ -121,6 +121,7 @@ "react-hook-form": "^7.71.2", "react-intersection-observer": "^10.0.0", "require-in-the-middle": "^7.5.2", + "sanitize-html": "^2.17.5", "sharp": "0.34.5", "tailwind-merge": "^3.4.0", "tailwindcss-animate": "^1.0.7", @@ -144,6 +145,7 @@ "@types/node": "22.19.17", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", + "@types/sanitize-html": "^2.16.1", "autoprefixer": "^10.4.20", "copyfiles": "^2.4.1", "dotenv": "^17.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0cd9f017d..f1ed32308 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -228,6 +228,9 @@ importers: require-in-the-middle: specifier: ^7.5.2 version: 7.5.2 + sanitize-html: + specifier: ^2.17.5 + version: 2.17.5 sharp: specifier: 0.34.5 version: 0.34.5 @@ -292,6 +295,9 @@ importers: '@types/react-dom': specifier: 19.2.3 version: 19.2.3(@types/react@19.2.14) + '@types/sanitize-html': + specifier: ^2.16.1 + version: 2.16.1 autoprefixer: specifier: ^10.4.20 version: 10.4.21(postcss@8.5.3) @@ -5925,6 +5931,12 @@ packages: integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==, } + '@types/sanitize-html@2.16.1': + resolution: + { + integrity: sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==, + } + '@types/set-cookie-parser@2.4.10': resolution: { @@ -7660,6 +7672,12 @@ packages: integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==, } + dayjs@1.11.21: + resolution: + { + integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==, + } + debounce-fn@6.0.0: resolution: { @@ -9307,6 +9325,12 @@ packages: } engines: { node: '>=14' } + htmlparser2@10.1.0: + resolution: + { + integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==, + } + htmlparser2@8.0.2: resolution: { @@ -9681,6 +9705,13 @@ packages: } engines: { node: '>=0.12.0' } + is-plain-object@5.0.0: + resolution: + { + integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==, + } + engines: { node: '>=0.10.0' } + is-potential-custom-element-name@1.0.1: resolution: { @@ -10288,6 +10319,12 @@ packages: } engines: { node: '>=0.10' } + launder@1.7.1: + resolution: + { + integrity: sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==, + } + leac@0.6.0: resolution: { @@ -11401,6 +11438,12 @@ packages: } engines: { node: '>=0.10.0' } + parse-srcset@1.0.2: + resolution: + { + integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==, + } + parse5@7.3.0: resolution: { @@ -12386,6 +12429,12 @@ packages: integrity: sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==, } + sanitize-html@2.17.5: + resolution: + { + integrity: sha512-ZmU1joGRrvoyctKIiuwUxqR6moLoU2Wk+2bMccN6f7UwhAmwYDvWziqPxRDDN2Qip62NqnIrVrT9akbL6Wretg==, + } + sass@1.77.4: resolution: { @@ -17812,6 +17861,10 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/sanitize-html@2.16.1': + dependencies: + htmlparser2: 10.1.0 + '@types/set-cookie-parser@2.4.10': dependencies: '@types/node': 22.19.17 @@ -18810,6 +18863,8 @@ snapshots: dateformat@4.6.3: {} + dayjs@1.11.21: {} + debounce-fn@6.0.0: dependencies: mimic-function: 5.0.1 @@ -19939,6 +19994,13 @@ snapshots: htmlparser2: 8.0.2 selderee: 0.11.0 + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + htmlparser2@8.0.2: dependencies: domelementtype: 2.3.0 @@ -20142,6 +20204,8 @@ snapshots: is-number@7.0.0: {} + is-plain-object@5.0.0: {} + is-potential-custom-element-name@1.0.1: {} is-promise@4.0.0: {} @@ -20699,6 +20763,10 @@ snapshots: dependencies: language-subtag-registry: 0.3.23 + launder@1.7.1: + dependencies: + dayjs: 1.11.21 + leac@0.6.0: {} leven@3.1.0: {} @@ -21486,6 +21554,8 @@ snapshots: parse-passwd@1.0.0: {} + parse-srcset@1.0.2: {} + parse5@7.3.0: dependencies: entities: 6.0.1 @@ -22178,6 +22248,16 @@ snapshots: dependencies: truncate-utf8-bytes: 1.0.2 + sanitize-html@2.17.5: + dependencies: + deepmerge: 4.3.1 + escape-string-regexp: 4.0.0 + htmlparser2: 10.1.0 + is-plain-object: 5.0.0 + launder: 1.7.1 + parse-srcset: 1.0.2 + postcss: 8.5.3 + sass@1.77.4: dependencies: chokidar: 3.6.0 diff --git a/public/images/danger-icons/0.png b/public/images/danger-icons/0.png new file mode 100644 index 000000000..e6761a536 Binary files /dev/null and b/public/images/danger-icons/0.png differ diff --git a/public/images/danger-icons/1.png b/public/images/danger-icons/1.png new file mode 100644 index 000000000..b80906bf4 Binary files /dev/null and b/public/images/danger-icons/1.png differ diff --git a/public/images/danger-icons/2.png b/public/images/danger-icons/2.png new file mode 100644 index 000000000..d56a7cd73 Binary files /dev/null and b/public/images/danger-icons/2.png differ diff --git a/public/images/danger-icons/3.png b/public/images/danger-icons/3.png new file mode 100644 index 000000000..6ec8b08a5 Binary files /dev/null and b/public/images/danger-icons/3.png differ diff --git a/public/images/danger-icons/4.png b/public/images/danger-icons/4.png new file mode 100644 index 000000000..f651e59e3 Binary files /dev/null and b/public/images/danger-icons/4.png differ diff --git a/public/images/danger-icons/5.png b/public/images/danger-icons/5.png new file mode 100644 index 000000000..f651e59e3 Binary files /dev/null and b/public/images/danger-icons/5.png differ diff --git a/public/images/problem-icons/CorniceFall.png b/public/images/problem-icons/CorniceFall.png new file mode 100644 index 000000000..affa4d078 Binary files /dev/null and b/public/images/problem-icons/CorniceFall.png differ diff --git a/public/images/problem-icons/DeepPersistentSlab.png b/public/images/problem-icons/DeepPersistentSlab.png new file mode 100644 index 000000000..f07a23225 Binary files /dev/null and b/public/images/problem-icons/DeepPersistentSlab.png differ diff --git a/public/images/problem-icons/DryLoose.png b/public/images/problem-icons/DryLoose.png new file mode 100644 index 000000000..c512b2dd1 Binary files /dev/null and b/public/images/problem-icons/DryLoose.png differ diff --git a/public/images/problem-icons/Glide.png b/public/images/problem-icons/Glide.png new file mode 100644 index 000000000..41f3252b4 Binary files /dev/null and b/public/images/problem-icons/Glide.png differ diff --git a/public/images/problem-icons/PersistentSlab.png b/public/images/problem-icons/PersistentSlab.png new file mode 100644 index 000000000..9cda9e7ca Binary files /dev/null and b/public/images/problem-icons/PersistentSlab.png differ diff --git a/public/images/problem-icons/StormSlab.png b/public/images/problem-icons/StormSlab.png new file mode 100644 index 000000000..a809a0ca8 Binary files /dev/null and b/public/images/problem-icons/StormSlab.png differ diff --git a/public/images/problem-icons/WetLoose.png b/public/images/problem-icons/WetLoose.png new file mode 100644 index 000000000..b25ff1ad7 Binary files /dev/null and b/public/images/problem-icons/WetLoose.png differ diff --git a/public/images/problem-icons/WetSlab.png b/public/images/problem-icons/WetSlab.png new file mode 100644 index 000000000..d494a1dd3 Binary files /dev/null and b/public/images/problem-icons/WetSlab.png differ diff --git a/public/images/problem-icons/WindSlab.png b/public/images/problem-icons/WindSlab.png new file mode 100644 index 000000000..cb20d7909 Binary files /dev/null and b/public/images/problem-icons/WindSlab.png differ diff --git a/src/app/(frontend)/[center]/forecasts/avalanche/[zone]/page.tsx b/src/app/(frontend)/[center]/forecasts/avalanche/[zone]/page.tsx index fc527375b..eb43d86ea 100644 --- a/src/app/(frontend)/[center]/forecasts/avalanche/[zone]/page.tsx +++ b/src/app/(frontend)/[center]/forecasts/avalanche/[zone]/page.tsx @@ -5,12 +5,17 @@ import { getPayload } from 'payload' import { NACWidget } from '@/components/NACWidget' import { WidgetRouterHandler } from '@/components/NACWidget/WidgetRouterHandler.client' +import { NativeForecastPage } from '@/components/forecast/NativeForecastPage' import { + fetchForecast, getActiveForecastZones, getAvalancheCenterPlatforms, getForecastZoneDanger, } from '@/services/nac/nac' +import { resolveZoneFromSlug } from '@/services/nac/resolveZone' +import { ProductType } from '@/services/nac/types/forecastSchemas' import { formatZoneName } from '@/utilities/formatZoneName' +import { getUseNativeForecasts } from '@/utilities/getUseNativeForecasts' import { notFound } from 'next/navigation' // ISR rather than fully static so the og:description travel advice in shared link previews @@ -59,6 +64,12 @@ export default async function Page({ params }: Args) { notFound() } + const useNative = await getUseNativeForecasts(center) + + if (useNative) { + return + } + return ( <> @@ -84,15 +95,27 @@ export async function generateMetadata( const parentOg = parentMeta.openGraph const zoneName = formatZoneName(zone) + const title = `${zoneName} - Avalanche Forecast | ${parentTitle}` + // Description: the forecaster's bottom line when native mode is on (richer), otherwise the + // map-layer travel advice. The og:image is always the live dynamic OG route. const danger = await getForecastZoneDanger(center, zone).catch(() => null) - const travelAdvice = danger?.travel_advice ?? null - - const title = `${zoneName} - Avalanche Forecast | ${parentTitle}` + let description = danger?.travel_advice ?? undefined + + const useNative = await getUseNativeForecasts(center) + if (useNative) { + const resolved = await resolveZoneFromSlug(center, zone) + if (resolved) { + const forecast = await fetchForecast(center, resolved.zone.id) + if (forecast && forecast.product_type === ProductType.Forecast && forecast.bottom_line) { + description = forecast.bottom_line + } + } + } return { title, - ...(travelAdvice ? { description: travelAdvice } : {}), + ...(description ? { description } : {}), alternates: { canonical: `/forecasts/avalanche/${zone}`, }, @@ -100,7 +123,7 @@ export async function generateMetadata( ...parentOg, title, url: `/forecasts/avalanche/${zone}`, - ...(travelAdvice ? { description: travelAdvice } : {}), + ...(description ? { description } : {}), images: [ { url: `/api/${center}/og?route=forecasts/avalanche/${zone}`, diff --git a/src/app/(frontend)/[center]/forecasts/avalanche/page.tsx b/src/app/(frontend)/[center]/forecasts/avalanche/page.tsx index d1244e513..d2b447e56 100644 --- a/src/app/(frontend)/[center]/forecasts/avalanche/page.tsx +++ b/src/app/(frontend)/[center]/forecasts/avalanche/page.tsx @@ -5,7 +5,9 @@ import { getPayload } from 'payload' import { NACWidget } from '@/components/NACWidget' import { WidgetRouterHandler } from '@/components/NACWidget/WidgetRouterHandler.client' +import { AllZonesForecast } from '@/components/forecast/AllZonesForecast' import { getAvalancheCenterPlatforms } from '@/services/nac/nac' +import { getUseNativeForecasts } from '@/utilities/getUseNativeForecasts' import { notFound } from 'next/navigation' import { ZoneLinkHijacker } from './ZoneLinkHijacker.client' @@ -41,6 +43,12 @@ export default async function Page({ params }: Args) { notFound() } + const useNative = await getUseNativeForecasts(center) + + if (useNative) { + return + } + return ( <> diff --git a/src/collections/Settings/index.ts b/src/collections/Settings/index.ts index 035f6a7ad..e1f5d57c1 100644 --- a/src/collections/Settings/index.ts +++ b/src/collections/Settings/index.ts @@ -215,6 +215,18 @@ const brandAssetsFields: Field[] = [ }, ] +const featuresFields: Field[] = [ + { + name: 'useNativeForecasts', + type: 'checkbox', + defaultValue: false, + admin: { + description: + 'When enabled, the forecast page renders natively using Next.js server components instead of the embedded widget. This can be toggled per center for incremental rollout.', + }, + }, +] + const socialMediaFields: Field[] = [ { name: 'socialMedia', @@ -325,6 +337,11 @@ export const Settings: CollectionConfig = { 'Images used throughout the website including in the header, footer, browser tabs, and link previews.', fields: brandAssetsFields, }, + { + label: 'Features', + description: 'Feature flags to control which features are enabled for this center.', + fields: featuresFields, + }, { label: 'Social Media', description: diff --git a/src/components/forecast/AllZonesForecast.tsx b/src/components/forecast/AllZonesForecast.tsx new file mode 100644 index 000000000..6e9f836e9 --- /dev/null +++ b/src/components/forecast/AllZonesForecast.tsx @@ -0,0 +1,64 @@ +/** + * All-zones forecast grid: fetches and renders compact forecast cards + * for every active zone in a center, in parallel. + */ +import { + fetchForecast, + fetchWarning, + getActiveForecastZones, + getAvalancheCenterMetadata, +} from '@/services/nac/nac' + +import { ForecastErrorBoundary } from './ForecastErrorBoundary' +import { ZoneForecastCard } from './ZoneForecastCard' + +interface AllZonesForecastProps { + centerSlug: string +} + +export async function AllZonesForecast({ centerSlug }: AllZonesForecastProps) { + // Metadata gives us the center timezone for rendering issued/expires times. + const [zones, metadata] = await Promise.all([ + getActiveForecastZones(centerSlug), + getAvalancheCenterMetadata(centerSlug), + ]) + + if (zones.length === 0) { + return ( +
        + No active forecast zones found. +
        + ) + } + + // Fetch all forecasts and warnings in parallel + const results = await Promise.all( + zones.map(async ({ slug, zone }) => { + const [forecast, warning] = await Promise.all([ + fetchForecast(centerSlug, zone.id), + fetchWarning(centerSlug, zone.id), + ]) + return { slug, zone, forecast, warning } + }), + ) + + return ( +
        + {results.map(({ slug, zone, forecast, warning }) => ( + + + + ))} +
        + ) +} diff --git a/src/components/forecast/AvalancheProblemCard.tsx b/src/components/forecast/AvalancheProblemCard.tsx new file mode 100644 index 000000000..bc44b9a48 --- /dev/null +++ b/src/components/forecast/AvalancheProblemCard.tsx @@ -0,0 +1,100 @@ +/** + * Avalanche problem card: problem icon/name, locator rose, likelihood + size sliders, + * discussion HTML, and media thumbnail. + */ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { + AvalancheProblemName, + MediaType, + type AvalancheProblem, +} from '@/services/nac/types/forecastSchemas' + +import { LocatorRose } from './LocatorRose' +import { LikelihoodSlider, SizeSlider } from './ProblemSlider' +import { sanitizeHtml } from './sanitizeHtml' + +/** Maps problem names to local icon filenames at /images/problem-icons/{name}.png */ +const problemIconFile: Record = { + [AvalancheProblemName.DryLoose]: 'DryLoose', + [AvalancheProblemName.StormSlab]: 'StormSlab', + [AvalancheProblemName.WindSlab]: 'WindSlab', + [AvalancheProblemName.PersistentSlab]: 'PersistentSlab', + [AvalancheProblemName.DeepPersistentSlab]: 'DeepPersistentSlab', + [AvalancheProblemName.WetLoose]: 'WetLoose', + [AvalancheProblemName.WetSlab]: 'WetSlab', + [AvalancheProblemName.CorniceFall]: 'CorniceFall', + [AvalancheProblemName.Glide]: 'Glide', + [AvalancheProblemName.GlideAvalanches]: 'Glide', +} + +function problemIconUrl(name: AvalancheProblemName): string { + return `/images/problem-icons/${problemIconFile[name]}.png` +} + +/** + * Returns a thumbnail URL for a problem's media item, or null if not displayable. + * Handles the image and photo media type variants. + */ +function mediaThumbnailUrl(media: AvalancheProblem['media']): string | null { + if (media.type === MediaType.Image) { + return media.url.thumbnail + } + if (media.type === MediaType.Photo) { + return typeof media.url === 'string' ? media.url : null + } + return null +} + +interface AvalancheProblemCardProps { + problem: AvalancheProblem +} + +export function AvalancheProblemCard({ problem }: AvalancheProblemCardProps) { + const thumbnail = mediaThumbnailUrl(problem.media) + + return ( + + + + {/* eslint-disable-next-line @next/next/no-img-element */} + + {problem.name} + + + + {/* Rose + sliders row */} +
        + +
        +
        + Likelihood + +
        +
        + Size + +
        +
        +
        + + {/* Discussion */} + {problem.discussion && ( +
        + )} + + {/* Media thumbnail */} + {thumbnail && ( + // eslint-disable-next-line @next/next/no-img-element + {problem.media.type + )} + + + ) +} diff --git a/src/components/forecast/BottomLine.tsx b/src/components/forecast/BottomLine.tsx new file mode 100644 index 000000000..9e98471e8 --- /dev/null +++ b/src/components/forecast/BottomLine.tsx @@ -0,0 +1,33 @@ +/** + * Bottom line summary: highest danger icon + sanitized HTML. + */ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { dangerIconUrl } from '@/services/nac/dangerScale' +import type { DangerLevel } from '@/services/nac/types/forecastSchemas' + +import { sanitizeHtml } from './sanitizeHtml' + +interface BottomLineProps { + html: string + dangerLevel: DangerLevel +} + +export function BottomLine({ html, dangerLevel }: BottomLineProps) { + return ( + + + + {/* eslint-disable-next-line @next/next/no-img-element */} + + The Bottom Line + + + +
        + + + ) +} diff --git a/src/components/forecast/DangerElevationBand.tsx b/src/components/forecast/DangerElevationBand.tsx new file mode 100644 index 000000000..408393a2c --- /dev/null +++ b/src/components/forecast/DangerElevationBand.tsx @@ -0,0 +1,36 @@ +/** + * Single elevation band row: label + colored bar with icon + danger name. + */ +import Image from 'next/image' + +import { dangerColor, dangerIconUrl, dangerName, dangerTextColor } from '@/services/nac/dangerScale' +import { DangerLevel } from '@/services/nac/types/forecastSchemas' + +import { sanitizeHtml } from './sanitizeHtml' + +interface DangerElevationBandProps { + label: string + level: DangerLevel +} + +export function DangerElevationBand({ label, level }: DangerElevationBandProps) { + const bgColor = dangerColor(level) + const textColor = dangerTextColor(level) + + return ( +
        + {/* Elevation labels may contain HTML (e.g. "Upper Elevations
        7500-5500ft") */} +
        +
        + {dangerName(level)} + {dangerName(level)} +
        +
        + ) +} diff --git a/src/components/forecast/DangerRating.tsx b/src/components/forecast/DangerRating.tsx new file mode 100644 index 000000000..1536c31da --- /dev/null +++ b/src/components/forecast/DangerRating.tsx @@ -0,0 +1,67 @@ +/** + * Danger rating section: DangerTriangle + 3 DangerElevationBand rows for today, + * plus a tomorrow outlook section. + */ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { type AvalancheDangerForecast, ForecastPeriod } from '@/services/nac/types/forecastSchemas' +import type { ElevationBandNames } from '@/services/nac/types/schemas' + +import { DangerElevationBand } from './DangerElevationBand' +import { DangerTriangle } from './DangerTriangle' + +interface DangerRatingProps { + danger: AvalancheDangerForecast[] + elevationBandNames: ElevationBandNames +} + +export function DangerRating({ danger, elevationBandNames }: DangerRatingProps) { + const today = danger.find((d) => d.valid_day === ForecastPeriod.Current) + const tomorrow = danger.find((d) => d.valid_day === ForecastPeriod.Tomorrow) + + return ( + + + Danger Rating + + + {today && ( + + )} + {tomorrow && ( + + )} + + + ) +} + +interface DangerDayProps { + label: string + forecast: AvalancheDangerForecast + elevationBandNames: ElevationBandNames +} + +function DangerDay({ label, forecast, elevationBandNames }: DangerDayProps) { + return ( +
        +

        {label}

        +
        + +
        + + + +
        +
        +
        + ) +} diff --git a/src/components/forecast/DangerTriangle.tsx b/src/components/forecast/DangerTriangle.tsx new file mode 100644 index 000000000..c6b6b3a1c --- /dev/null +++ b/src/components/forecast/DangerTriangle.tsx @@ -0,0 +1,40 @@ +/** + * Danger triangle SVG — 3-trapezoid pyramid colored by danger level per elevation band. + * Pixel-perfect port from avy/components/AvalancheDangerTriangle.tsx. + * SVG path data copied verbatim from the AvyApp source. + */ +import { dangerColor } from '@/services/nac/dangerScale' +import { DangerLevel } from '@/services/nac/types/forecastSchemas' + +interface DangerTriangleProps { + upper: DangerLevel + middle: DangerLevel + lower: DangerLevel + className?: string +} + +export function DangerTriangle({ upper, middle, lower, className }: DangerTriangleProps) { + return ( + + + + + + ) +} diff --git a/src/components/forecast/ForecastDiscussion.tsx b/src/components/forecast/ForecastDiscussion.tsx new file mode 100644 index 000000000..83d3fc296 --- /dev/null +++ b/src/components/forecast/ForecastDiscussion.tsx @@ -0,0 +1,26 @@ +/** + * Forecast discussion: sanitized HTML rendering. + */ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' + +import { sanitizeHtml } from './sanitizeHtml' + +interface ForecastDiscussionProps { + html: string +} + +export function ForecastDiscussion({ html }: ForecastDiscussionProps) { + return ( + + + Forecast Discussion + + +
        + + + ) +} diff --git a/src/components/forecast/ForecastErrorBoundary.tsx b/src/components/forecast/ForecastErrorBoundary.tsx new file mode 100644 index 000000000..1fb495d8c --- /dev/null +++ b/src/components/forecast/ForecastErrorBoundary.tsx @@ -0,0 +1,47 @@ +'use client' + +import { Component, type ErrorInfo, type ReactNode } from 'react' + +interface ForecastErrorBoundaryProps { + fallbackMessage: string + children: ReactNode +} + +interface ForecastErrorBoundaryState { + hasError: boolean +} + +/** + * Section-level error boundary for forecast components. + * When a child throws during rendering, displays a styled fallback message + * instead of crashing the entire page. + */ +export class ForecastErrorBoundary extends Component< + ForecastErrorBoundaryProps, + ForecastErrorBoundaryState +> { + constructor(props: ForecastErrorBoundaryProps) { + super(props) + this.state = { hasError: false } + } + + static getDerivedStateFromError(): ForecastErrorBoundaryState { + return { hasError: true } + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + console.error(`[ForecastErrorBoundary] ${this.props.fallbackMessage}:`, error, errorInfo) + } + + render() { + if (this.state.hasError) { + return ( +
        + {this.props.fallbackMessage} +
        + ) + } + + return this.props.children + } +} diff --git a/src/components/forecast/ForecastHeader.tsx b/src/components/forecast/ForecastHeader.tsx new file mode 100644 index 000000000..257debfb8 --- /dev/null +++ b/src/components/forecast/ForecastHeader.tsx @@ -0,0 +1,49 @@ +/** + * Forecast metadata: issued time, expiration time, and author name. + * + * Rendered as a plain block (no Card) so it composes inside the all-zones zone + * card without nesting a card in a card. Times are formatted in the avalanche + * center's timezone (from the NAC metadata) because the page is server-rendered + * and has no client locale to fall back on. + */ +import type { Forecast, Summary } from '@/services/nac/types/forecastSchemas' +import { formatDateTime } from '@/utilities/formatDateTime' + +interface ForecastHeaderProps { + forecast: Pick + timezone: string | null | undefined +} + +const DATE_FORMAT = "EEEE, MMMM d, yyyy 'at' h:mm a zzz" + +function formatInZone(iso: string | null, timezone: string | null | undefined): string | null { + if (!iso) return null + const date = new Date(iso) + if (isNaN(date.getTime())) return null + return formatDateTime(iso, timezone, DATE_FORMAT) +} + +export function ForecastHeader({ forecast, timezone }: ForecastHeaderProps) { + const issued = formatInZone(forecast.published_time, timezone) + const expires = formatInZone(forecast.expires_time, timezone) + + return ( +
        + {forecast.author && ( +

        + Author: {forecast.author} +

        + )} + {issued && ( +

        + Issued: {issued} +

        + )} + {expires && ( +

        + Expires: {expires} +

        + )} +
        + ) +} diff --git a/src/components/forecast/ForecastMediaThumbnails.tsx b/src/components/forecast/ForecastMediaThumbnails.tsx new file mode 100644 index 000000000..5070cb905 --- /dev/null +++ b/src/components/forecast/ForecastMediaThumbnails.tsx @@ -0,0 +1,74 @@ +'use client' + +import { useState } from 'react' + +import { type MediaItem, MediaType } from '@/services/nac/types/forecastSchemas' + +import { getThumbnailUrl, MediaLightbox } from './MediaLightbox' + +interface ForecastMediaThumbnailsProps { + media: MediaItem[] +} + +/** Filter to media items that have something to display in the lightbox */ +function displayableMedia(items: MediaItem[]): MediaItem[] { + return items.filter((item): item is MediaItem & { type: MediaType } => { + const t = item.type + return ( + t === MediaType.Image || + t === MediaType.Video || + t === MediaType.Photo || + t === MediaType.External || + t === MediaType.PDF + ) + }) +} + +export function ForecastMediaThumbnails({ media }: ForecastMediaThumbnailsProps) { + const [lightboxOpen, setLightboxOpen] = useState(false) + const [lightboxIndex, setLightboxIndex] = useState(0) + + const items = displayableMedia(media) + if (items.length === 0) return null + + return ( + <> +
        + {items.map((item, idx) => { + const thumbUrl = getThumbnailUrl(item) + return ( + + ) + })} +
        + + + + ) +} diff --git a/src/components/forecast/LocatorRose.tsx b/src/components/forecast/LocatorRose.tsx new file mode 100644 index 000000000..a2446f3ea --- /dev/null +++ b/src/components/forecast/LocatorRose.tsx @@ -0,0 +1,119 @@ +/** + * Locator rose SVG — 24-sector rose (8 aspects × 3 elevations) showing which + * aspects and elevations an avalanche problem affects. + * Pixel-perfect port from avy/components/DangerRose.tsx. + * SVG path data copied verbatim from the AvyApp source. + */ +'use client' + +import { AvalancheProblemLocation } from '@/services/nac/types/forecastSchemas' + +interface LocatorRoseProps { + locations: AvalancheProblemLocation[] + className?: string +} + +/** + * SVG path data for each of the 24 sectors. Keys match AvalancheProblemLocation enum values. + * Paths copied verbatim from avy/components/DangerRose.tsx. + */ +const sectorPaths: Record = { + [AvalancheProblemLocation.NorthUpper]: 'M529.716,527l68.371,-166.7l-138.1,0l69.729,166.7Z', + [AvalancheProblemLocation.NorthMiddle]: + 'M666.581,193.63l-277.081,0l69.27,166.67l138.541,0l69.27,-166.67Z', + [AvalancheProblemLocation.NorthLower]: + 'M734.1,26.997l-414.2,0l69.943,166.67l275.865,0l68.392,-166.67Z', + [AvalancheProblemLocation.NortheastUpper]: + 'M529.716,527l166.22,-69.529l-97.651,-97.652l-68.569,167.181Z', + [AvalancheProblemLocation.NortheastMiddle]: + 'M862.222,388.05l-195.925,-195.926l-68.873,166.835l97.963,97.963l166.835,-68.872Z', + [AvalancheProblemLocation.NortheastLower]: + 'M1027.79,317.966l-292.884,-292.884l-68.396,167.311l195.066,195.066l166.214,-69.493Z', + [AvalancheProblemLocation.EastUpper]: 'M527.716,528.339l166.7,68.371l0,-138.1l-166.7,69.729Z', + [AvalancheProblemLocation.EastMiddle]: + 'M861.086,665.204l0,-277.081l-166.67,69.27l0,138.541l166.67,69.27Z', + [AvalancheProblemLocation.EastLower]: + 'M1027.72,732.723l0,-414.2l-166.67,69.943l0,275.865l166.67,68.392Z', + [AvalancheProblemLocation.SoutheastUpper]: + 'M527.716,528.339l69.529,166.22l97.652,-97.651l-167.181,-68.569Z', + [AvalancheProblemLocation.SoutheastMiddle]: + 'M666.666,860.845l195.926,-195.925l-166.835,-68.872l-97.963,97.962l68.872,166.835Z', + [AvalancheProblemLocation.SoutheastLower]: + 'M736.75,1026.42l292.884,-292.884l-167.311,-68.396l-195.066,195.066l69.493,166.214Z', + [AvalancheProblemLocation.SouthUpper]: 'M527.918,528.416l-68.371,166.7l138.1,0l-69.729,-166.7Z', + [AvalancheProblemLocation.SouthMiddle]: + 'M391.053,861.786l277.081,0l-69.27,-166.67l-138.541,0l-69.27,166.67Z', + [AvalancheProblemLocation.SouthLower]: + 'M323.534,1028.42l414.2,0l-69.943,-166.67l-275.865,0l-68.392,166.67Z', + [AvalancheProblemLocation.SouthwestUpper]: + 'M527.918,528.416l-166.22,69.529l97.651,97.652l68.569,-167.181Z', + [AvalancheProblemLocation.SouthwestMiddle]: + 'M195.412,667.366l195.926,195.926l68.872,-166.835l-97.963,-97.963l-166.835,68.872Z', + [AvalancheProblemLocation.SouthwestLower]: + 'M29.841,737.451l292.884,292.883l68.396,-167.31l-195.066,-195.067l-166.214,69.494Z', + [AvalancheProblemLocation.WestUpper]: 'M528.918,527.077l-166.7,-68.371l0,138.1l166.7,-69.729Z', + [AvalancheProblemLocation.WestMiddle]: + 'M195.548,390.212l0,277.081l166.67,-69.27l0,-138.541l-166.67,-69.27Z', + [AvalancheProblemLocation.WestLower]: + 'M28.915,322.693l0,414.2l166.67,-69.943l0,-275.865l-166.67,-68.392Z', + [AvalancheProblemLocation.NorthwestUpper]: + 'M529.918,527.077l-69.529,-166.22l-97.652,97.651l167.181,68.569Z', + [AvalancheProblemLocation.NorthwestMiddle]: + 'M390.968,194.571l-195.926,195.926l166.835,68.872l97.963,-97.963l-68.872,-166.835Z', + [AvalancheProblemLocation.NorthwestLower]: + 'M318.884,27l-292.884,292.884l167.311,68.396l195.066,-195.066l-69.493,-166.214Z', +} + +const ACTIVE_FILL = 'rgb(200, 202, 206)' +const INACTIVE_FILL = 'transparent' +const STROKE_COLOR = 'rgb(81, 85, 88)' + +/** Cardinal/intercardinal labels positioned around the rose */ +const directionLabels = [ + { label: 'N', x: 528, y: -30 }, + { label: 'NE', x: 1070, y: 280 }, + { label: 'E', x: 1070, y: 540 }, + { label: 'SE', x: 1070, y: 800 }, + { label: 'S', x: 528, y: 1090 }, + { label: 'SW', x: -20, y: 800 }, + { label: 'W', x: -20, y: 540 }, + { label: 'NW', x: -20, y: 280 }, +] as const + +export function LocatorRose({ locations, className }: LocatorRoseProps) { + const locationSet = new Set(locations) + + return ( + + {Object.values(AvalancheProblemLocation).map((location) => ( + + ))} + {directionLabels.map(({ label, x, y }) => ( + + {label} + + ))} + + ) +} diff --git a/src/components/forecast/MediaLightbox.tsx b/src/components/forecast/MediaLightbox.tsx new file mode 100644 index 000000000..8168a609b --- /dev/null +++ b/src/components/forecast/MediaLightbox.tsx @@ -0,0 +1,204 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' + +import { + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, + type CarouselApi, +} from '@/components/ui/carousel' +import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog' +import { MediaType, type MediaItem } from '@/services/nac/types/forecastSchemas' + +import { sanitizeHtml } from './sanitizeHtml' + +interface MediaLightboxProps { + media: MediaItem[] + initialIndex: number + open: boolean + onOpenChange: (open: boolean) => void +} + +function getThumbnailUrl(item: MediaItem): string | null { + if (item.type === MediaType.Image) return item.url.thumbnail + if (item.type === MediaType.Photo) return typeof item.url === 'string' ? item.url : null + if (item.type === MediaType.Video) { + if (typeof item.url === 'object' && 'thumbnail' in item.url) return item.url.thumbnail + return null + } + return null +} + +function getFullUrl(item: MediaItem): string | null { + if (item.type === MediaType.Image) return item.url.original + if (item.type === MediaType.Photo) return typeof item.url === 'string' ? item.url : null + return null +} + +/** Extract YouTube video_id from a Video media item */ +function getYouTubeVideoId(item: MediaItem): string | null { + if (item.type !== MediaType.Video) return null + if (typeof item.url === 'object' && 'video_id' in item.url) return item.url.video_id + return null +} + +function getCaption(item: MediaItem): string | null { + if ('caption' in item && item.caption) return item.caption + return null +} + +export function MediaLightbox({ media, initialIndex, open, onOpenChange }: MediaLightboxProps) { + const [api, setApi] = useState() + const [current, setCurrent] = useState(initialIndex) + + useEffect(() => { + if (!api) return + // Jump to the initial index when the lightbox opens or initialIndex changes + api.scrollTo(initialIndex, true) + setCurrent(initialIndex) + }, [api, initialIndex]) + + useEffect(() => { + if (!api) return + const onSelect = () => setCurrent(api.selectedScrollSnap()) + api.on('select', onSelect) + return () => { + api.off('select', onSelect) + } + }, [api]) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'ArrowLeft') { + e.preventDefault() + api?.scrollPrev() + } else if (e.key === 'ArrowRight') { + e.preventDefault() + api?.scrollNext() + } + }, + [api], + ) + + if (media.length === 0) return null + + return ( + + + Media viewer + + Viewing {current + 1} of {media.length} media items. Use arrow keys to navigate. + + +
        + 1 }}> + + {media.map((item, idx) => ( + +
        + +
        +
        + ))} +
        + + {media.length > 1 && ( + <> + + + + )} +
        + + {/* Caption and counter */} +
        + {/* Captions may contain HTML tags (e.g.

        ,  ) */} + {getCaption(media[current]) && ( +

        + )} + {media.length > 1 && ( +

        + {current + 1} / {media.length} +

        + )} +
        +
        + +
        + ) +} + +function MediaSlide({ item }: { item: MediaItem }) { + const videoId = getYouTubeVideoId(item) + if (videoId) { + return ( +
        +