feat: add global weather support
All checks were successful
CI / Lint, typecheck and build (push) Successful in 9m54s
All checks were successful
CI / Lint, typecheck and build (push) Successful in 9m54s
This commit is contained in:
@@ -41,7 +41,7 @@ export async function GET(request: Request) {
|
||||
try {
|
||||
const now = Date.now();
|
||||
const warnings = (await fetchMeteoWarnings(AbortSignal.timeout(12_000))).filter((warning) => isRelevantWarning(warning, now));
|
||||
const subscriptions = getPushSubscriptions().filter((subscription) => subscription.enabled);
|
||||
const subscriptions = getPushSubscriptions().filter((subscription) => subscription.region === "PL" && subscription.enabled && subscription.province);
|
||||
const activeWarningIds = new Set(warnings.map((warning) => warning.id));
|
||||
let sent = 0;
|
||||
let skipped = 0;
|
||||
@@ -51,7 +51,7 @@ export async function GET(request: Request) {
|
||||
|
||||
for (const subscription of subscriptions) {
|
||||
const matchingWarnings = warnings.filter((warning) => (
|
||||
subscription.countyTeryt ? warningMatchesCounty(warning, subscription.countyTeryt) : warning.provinces.includes(subscription.province)
|
||||
subscription.countyTeryt ? warningMatchesCounty(warning, subscription.countyTeryt) : subscription.province !== null && warning.provinces.includes(subscription.province)
|
||||
));
|
||||
for (const warning of matchingWarnings) {
|
||||
if (hasSentWarning(subscription.endpoint, warning.id)) {
|
||||
|
||||
@@ -16,15 +16,27 @@ function isCronAuthorized(request: Request) {
|
||||
return authHeader === `Bearer ${secret}` || secretHeader === secret;
|
||||
}
|
||||
|
||||
function getWarsawDateKey(date = new Date()) {
|
||||
const DEFAULT_MORNING_BRIEF_TIME = "07:00";
|
||||
|
||||
function normalizeTime(value: string | undefined, fallback: string) {
|
||||
return value && /^\d{2}:\d{2}$/.test(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function getLocalDateParts(timeZone: string | null, date = new Date()) {
|
||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Warsaw",
|
||||
timeZone: timeZone || "Europe/Warsaw",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
}).formatToParts(date);
|
||||
const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((entry) => entry.type === type)?.value ?? "";
|
||||
return `${part("year")}-${part("month")}-${part("day")}`;
|
||||
return {
|
||||
dateKey: `${part("year")}-${part("month")}-${part("day")}`,
|
||||
time: `${part("hour")}:${part("minute")}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
@@ -36,35 +48,43 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const dateKey = getWarsawDateKey(now);
|
||||
const briefTime = normalizeTime(process.env.NOTIFICATIONS_MORNING_BRIEF_TIME, DEFAULT_MORNING_BRIEF_TIME);
|
||||
const subscriptions = getPushSubscriptions().filter((subscription) => (
|
||||
subscription.enabled
|
||||
&& subscription.morningBriefEnabled
|
||||
subscription.morningBriefEnabled
|
||||
&& Number.isFinite(subscription.latitude)
|
||||
&& Number.isFinite(subscription.longitude)
|
||||
));
|
||||
let warningsUnavailable = false;
|
||||
const warnings = await fetchMeteoWarnings(AbortSignal.timeout(12_000)).catch(() => {
|
||||
warningsUnavailable = true;
|
||||
return [];
|
||||
});
|
||||
const hasPolishSubscriptions = subscriptions.some((subscription) => subscription.region === "PL");
|
||||
const warnings = hasPolishSubscriptions
|
||||
? await fetchMeteoWarnings(AbortSignal.timeout(12_000)).catch(() => {
|
||||
warningsUnavailable = true;
|
||||
return [];
|
||||
})
|
||||
: [];
|
||||
let sent = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const subscription of subscriptions) {
|
||||
const localTime = getLocalDateParts(subscription.timezone, now);
|
||||
if (localTime.time < briefTime) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
const dateKey = localTime.dateKey;
|
||||
if (hasSentMorningBrief(subscription.endpoint, dateKey)) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const forecast = await fetchServerForecast(subscription.latitude as number, subscription.longitude as number);
|
||||
const forecast = await fetchServerForecast(subscription.latitude as number, subscription.longitude as number, subscription.region);
|
||||
const brief = buildWeatherBrief({
|
||||
forecast,
|
||||
warnings,
|
||||
warnings: subscription.region === "PL" ? warnings : [],
|
||||
province: subscription.province,
|
||||
countyTeryt: subscription.countyTeryt,
|
||||
countyTeryt: subscription.region === "PL" ? subscription.countyTeryt : null,
|
||||
locationName: subscription.locationName ?? "wtr.",
|
||||
language: subscription.language,
|
||||
temperatureUnit: subscription.temperatureUnit ?? DEFAULT_TEMPERATURE_UNIT,
|
||||
@@ -87,7 +107,7 @@ export async function GET(request: Request) {
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
date: dateKey,
|
||||
time: briefTime,
|
||||
subscriptions: subscriptions.length,
|
||||
warningsUnavailable,
|
||||
sent,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { normalizeProvinceName } from "@/lib/provinces";
|
||||
import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT, isTemperatureUnit, isWindSpeedUnit } from "@/lib/weather-utils";
|
||||
import type { Language } from "@/lib/i18n";
|
||||
import type { BrowserPushSubscription } from "@/types/notifications";
|
||||
import type { WeatherRegion } from "@/types/weather-region";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -25,6 +26,7 @@ export async function POST(request: Request) {
|
||||
const body = await request.json().catch(() => null) as {
|
||||
subscription?: unknown;
|
||||
province?: unknown;
|
||||
region?: unknown;
|
||||
language?: unknown;
|
||||
enabled?: unknown;
|
||||
morningBriefEnabled?: unknown;
|
||||
@@ -32,17 +34,20 @@ export async function POST(request: Request) {
|
||||
latitude?: unknown;
|
||||
longitude?: unknown;
|
||||
locationName?: unknown;
|
||||
timezone?: unknown;
|
||||
countyTeryt?: unknown;
|
||||
temperatureUnit?: unknown;
|
||||
windSpeedUnit?: unknown;
|
||||
} | null;
|
||||
const subscription = body?.subscription;
|
||||
const region: WeatherRegion = body?.region === "GLOBAL" ? "GLOBAL" : "PL";
|
||||
const province = normalizeProvinceName(typeof body?.province === "string" ? body.province : null);
|
||||
const language: Language = body?.language === "en" ? "en" : "pl";
|
||||
const rawTemperatureUnit = body?.temperatureUnit;
|
||||
const rawWindSpeedUnit = body?.windSpeedUnit;
|
||||
|
||||
if (!isBrowserPushSubscription(subscription) || !province) {
|
||||
const enabled = body?.enabled !== false && region === "PL";
|
||||
if (!isBrowserPushSubscription(subscription) || (enabled && !province)) {
|
||||
return NextResponse.json({ error: "Invalid push subscription." }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -51,13 +56,15 @@ export async function POST(request: Request) {
|
||||
endpoint: subscription.endpoint,
|
||||
subscription,
|
||||
province,
|
||||
region,
|
||||
language,
|
||||
enabled: body?.enabled !== false,
|
||||
enabled,
|
||||
morningBriefEnabled: body?.morningBriefEnabled === true,
|
||||
tomorrowBriefEnabled: body?.tomorrowBriefEnabled === true,
|
||||
latitude: typeof body?.latitude === "number" && Number.isFinite(body.latitude) ? body.latitude : null,
|
||||
longitude: typeof body?.longitude === "number" && Number.isFinite(body.longitude) ? body.longitude : null,
|
||||
locationName: typeof body?.locationName === "string" && body.locationName.trim() ? body.locationName.trim().slice(0, 80) : null,
|
||||
timezone: typeof body?.timezone === "string" && body.timezone.trim() ? body.timezone.trim().slice(0, 80) : null,
|
||||
countyTeryt: typeof body?.countyTeryt === "string" && /^\d{4}$/.test(body.countyTeryt) ? body.countyTeryt : null,
|
||||
temperatureUnit: isTemperatureUnit(rawTemperatureUnit) ? rawTemperatureUnit : DEFAULT_TEMPERATURE_UNIT,
|
||||
windSpeedUnit: isWindSpeedUnit(rawWindSpeedUnit) ? rawWindSpeedUnit : DEFAULT_WIND_SPEED_UNIT,
|
||||
|
||||
@@ -16,25 +16,36 @@ function isCronAuthorized(request: Request) {
|
||||
return authHeader === `Bearer ${secret}` || secretHeader === secret;
|
||||
}
|
||||
|
||||
function getWarsawDateKey(date = new Date(), dayOffset = 0) {
|
||||
const DEFAULT_TOMORROW_BRIEF_TIME = "18:00";
|
||||
|
||||
function normalizeTime(value: string | undefined, fallback: string) {
|
||||
return value && /^\d{2}:\d{2}$/.test(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function getLocalDateParts(timeZone: string | null, date = new Date(), dayOffset = 0) {
|
||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Warsaw",
|
||||
timeZone: timeZone || "Europe/Warsaw",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
}).formatToParts(date);
|
||||
const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((entry) => entry.type === type)?.value ?? "";
|
||||
if (dayOffset === 0) return `${part("year")}-${part("month")}-${part("day")}`;
|
||||
const dateKey = `${part("year")}-${part("month")}-${part("day")}`;
|
||||
const time = `${part("hour")}:${part("minute")}`;
|
||||
if (dayOffset === 0) return { dateKey, time };
|
||||
|
||||
const shiftedDate = new Date(Date.UTC(Number(part("year")), Number(part("month")) - 1, Number(part("day")) + dayOffset, 12));
|
||||
const shiftedParts = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Warsaw",
|
||||
timeZone: timeZone || "Europe/Warsaw",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(shiftedDate);
|
||||
const shiftedPart = (type: Intl.DateTimeFormatPartTypes) => shiftedParts.find((entry) => entry.type === type)?.value ?? "";
|
||||
return `${shiftedPart("year")}-${shiftedPart("month")}-${shiftedPart("day")}`;
|
||||
return { dateKey: `${shiftedPart("year")}-${shiftedPart("month")}-${shiftedPart("day")}`, time };
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
@@ -46,35 +57,43 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const targetDateKey = getWarsawDateKey(now, 1);
|
||||
const briefTime = normalizeTime(process.env.NOTIFICATIONS_TOMORROW_BRIEF_TIME, DEFAULT_TOMORROW_BRIEF_TIME);
|
||||
const subscriptions = getPushSubscriptions().filter((subscription) => (
|
||||
subscription.enabled
|
||||
&& subscription.tomorrowBriefEnabled
|
||||
subscription.tomorrowBriefEnabled
|
||||
&& Number.isFinite(subscription.latitude)
|
||||
&& Number.isFinite(subscription.longitude)
|
||||
));
|
||||
let warningsUnavailable = false;
|
||||
const warnings = await fetchMeteoWarnings(AbortSignal.timeout(12_000)).catch(() => {
|
||||
warningsUnavailable = true;
|
||||
return [];
|
||||
});
|
||||
const hasPolishSubscriptions = subscriptions.some((subscription) => subscription.region === "PL");
|
||||
const warnings = hasPolishSubscriptions
|
||||
? await fetchMeteoWarnings(AbortSignal.timeout(12_000)).catch(() => {
|
||||
warningsUnavailable = true;
|
||||
return [];
|
||||
})
|
||||
: [];
|
||||
let sent = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const subscription of subscriptions) {
|
||||
const localToday = getLocalDateParts(subscription.timezone, now);
|
||||
if (localToday.time < briefTime) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
const targetDateKey = getLocalDateParts(subscription.timezone, now, 1).dateKey;
|
||||
if (hasSentTomorrowBrief(subscription.endpoint, targetDateKey)) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const forecast = await fetchServerForecast(subscription.latitude as number, subscription.longitude as number);
|
||||
const forecast = await fetchServerForecast(subscription.latitude as number, subscription.longitude as number, subscription.region);
|
||||
const brief = buildTomorrowWeatherBrief({
|
||||
forecast,
|
||||
warnings,
|
||||
warnings: subscription.region === "PL" ? warnings : [],
|
||||
province: subscription.province,
|
||||
countyTeryt: subscription.countyTeryt,
|
||||
countyTeryt: subscription.region === "PL" ? subscription.countyTeryt : null,
|
||||
locationName: subscription.locationName ?? "wtr.",
|
||||
language: subscription.language,
|
||||
temperatureUnit: subscription.temperatureUnit ?? DEFAULT_TEMPERATURE_UNIT,
|
||||
@@ -97,7 +116,7 @@ export async function GET(request: Request) {
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
date: targetDateKey,
|
||||
time: briefTime,
|
||||
subscriptions: subscriptions.length,
|
||||
warningsUnavailable,
|
||||
sent,
|
||||
|
||||
Reference in New Issue
Block a user