8 Commits
Author SHA1 Message Date
alex 8eecce00a6 Release v0.0.6: resilient BOM metadata parsing.
Read C07_WeatherMetadata via innerText, accept observation prefixes (a minute,
N minutes, an hour, N hours) and forecast N hours ago. Version 0.0.6.
2026-05-07 05:49:51 +10:00
alex 28e8cb4fd1 Bump version to 0.0.5 for release (v0.0.4 already exists on GitHub). 2026-05-07 05:23:49 +10:00
alex a54061f757 Release v0.0.4: BOM spatial map scraping and radar cache reliability.
Align selectors and workflow with the step-through scrubber, share scrub display
timestamp parsing via IScrubDisplayTimestampParser, fix pause post-click label
verification, and stop cancelling background cache updates when the HTTP request
ends. Version bumped to 0.0.4.
2026-05-07 05:23:18 +10:00
alex 0b6d266e79 Replace large image with smaller icon in README 2025-12-24 02:15:35 +10:00
alex 1a257931e8 Refactor SPA: extract CSS/JS to modular files, add static file serving, fix timestamp parsing 2025-12-24 00:11:09 +10:00
alex 0a29c2d0c1 Fix frame timestamp parsing: detect AEST/AEDT and use parsed date directly 2025-12-24 00:09:50 +10:00
alex ffd3636d1c Remove minutes ago parsing, use UTC timestamps directly
- Refactor TimeParsingService to parse time strings to UTC only
- Remove minutesAgo from RadarFrame and FrameMetadata models
- Update CaptureFramesStep to extract UTC timestamps directly
- Update CacheService to store/load ObservationTime instead of MinutesAgo
- Update BomRadarService to use AbsoluteObservationTime directly
- Remove all backward compatibility code for old cache folders
- Update documentation and test SPA to calculate minutes ago on client
- Fix step registration to register concrete types for DI resolution
- Fix hosted service registration to use reflection correctly
2025-12-23 21:52:42 +10:00
alex b30d76ddec Refactor configuration system and remove OpenAPI
- Remove OpenAPI/Swagger support and all related code
- Standardize configuration: remove hardcoded defaults, all defaults from appsettings.json
- Configuration values throw exceptions if missing (except Debug:Enabled defaults to false)
- Refactor CacheManagementService: extract ProcessLocationsAsync helper, remove duplicate code
- Combine UpdateStaggerSeconds and LocationStaggerSeconds into single LocationStaggerSeconds config
- Move config reads to constructors for better performance
- Update README and docker-compose.yml to reflect configuration changes
2025-12-23 21:03:21 +10:00
67 changed files with 3703 additions and 2837 deletions
+1 -1
View File
@@ -4,10 +4,10 @@
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>0.0.6</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" />
<PackageReference Include="Microsoft.Playwright" Version="1.57.0" />
</ItemGroup>
+34 -5
View File
@@ -60,8 +60,19 @@ public class RadarController : ControllerBase
{
// Get cache status to include in 404 response
// Use cache service directly since this is cache management, not radar-specific
var cacheExpirationMinutes = (int)_configuration.GetValue<double>("CacheExpirationMinutes", 15.5);
var cacheManagementCheckIntervalMinutes = _configuration.GetValue<int>("CacheManagement:CheckIntervalMinutes", 5);
var cacheExpirationMinutesConfig = _configuration.GetValue<double?>("CacheExpirationMinutes");
if (!cacheExpirationMinutesConfig.HasValue)
{
throw new InvalidOperationException("CacheExpirationMinutes configuration is required. Set it in appsettings.json or via CACHEEXPIRATIONMINUTES environment variable.");
}
var cacheExpirationMinutes = (int)cacheExpirationMinutesConfig.Value;
var cacheManagementCheckIntervalMinutesConfig = _configuration.GetValue<int?>("CacheManagement:CheckIntervalMinutes");
if (!cacheManagementCheckIntervalMinutesConfig.HasValue)
{
throw new InvalidOperationException("CacheManagement:CheckIntervalMinutes configuration is required. Set it in appsettings.json or via CACHEMANAGEMENT__CHECKINTERVALMINUTES environment variable.");
}
var cacheManagementCheckIntervalMinutes = cacheManagementCheckIntervalMinutesConfig.Value;
var cacheStatus = await _cacheService.GetCacheStatusAsync(
suburb,
state,
@@ -288,7 +299,14 @@ public class RadarController : ControllerBase
// Validate time range size to prevent excessive data loading
// Default: base limit on cache retention, but allow override via config
var cacheRetentionHours = _configuration.GetValue<int>("CacheRetentionHours", 24);
var cacheRetentionHoursConfig = _configuration.GetValue<int?>("CacheRetentionHours");
if (!cacheRetentionHoursConfig.HasValue)
{
throw new InvalidOperationException("CacheRetentionHours configuration is required. Set it in appsettings.json or via CACHERETENTIONHOURS environment variable.");
}
var cacheRetentionHours = cacheRetentionHoursConfig.Value;
// TimeSeries:MaxTimeRangeHours is optional (nullable)
var configuredMaxHours = _configuration.GetValue<int?>("TimeSeries:MaxTimeRangeHours");
// Use configured value if set, otherwise use cache retention (with minimum of 24 hours)
@@ -331,8 +349,19 @@ public class RadarController : ControllerBase
});
// Get cache status to include in 404 response
var cacheExpirationMinutes = (int)_configuration.GetValue<double>("CacheExpirationMinutes", 15.5);
var cacheManagementCheckIntervalMinutes = _configuration.GetValue<int>("CacheManagement:CheckIntervalMinutes", 5);
var cacheExpirationMinutesConfig = _configuration.GetValue<double?>("CacheExpirationMinutes");
if (!cacheExpirationMinutesConfig.HasValue)
{
throw new InvalidOperationException("CacheExpirationMinutes configuration is required. Set it in appsettings.json or via CACHEEXPIRATIONMINUTES environment variable.");
}
var cacheExpirationMinutes = (int)cacheExpirationMinutesConfig.Value;
var cacheManagementCheckIntervalMinutesConfig = _configuration.GetValue<int?>("CacheManagement:CheckIntervalMinutes");
if (!cacheManagementCheckIntervalMinutesConfig.HasValue)
{
throw new InvalidOperationException("CacheManagement:CheckIntervalMinutes configuration is required. Set it in appsettings.json or via CACHEMANAGEMENT__CHECKINTERVALMINUTES environment variable.");
}
var cacheManagementCheckIntervalMinutes = cacheManagementCheckIntervalMinutesConfig.Value;
var cacheStatus = await _cacheService.GetCacheStatusAsync(
suburb,
state,
+259
View File
@@ -0,0 +1,259 @@
using BomLocalService.Services.Interfaces;
using BomLocalService.Services.Interfaces.Registration;
using BomLocalService.Services.Scraping;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using System.Reflection;
namespace BomLocalService.Extensions;
public static class ServiceCollectionExtensions
{
/// <summary>
/// Configures CORS with settings from configuration.
/// Configuration values come from appsettings.json (defaults) and can be overridden via environment variables.
/// Environment variables use double underscore for nested keys (e.g., CORS__ALLOWEDORIGINS).
/// </summary>
public static IServiceCollection AddCorsConfiguration(this IServiceCollection services, IConfiguration configuration)
{
var corsOrigins = configuration.GetValue<string>("Cors:AllowedOrigins")
?? throw new InvalidOperationException("Cors:AllowedOrigins configuration is required. Set it in appsettings.json or via CORS__ALLOWEDORIGINS environment variable.");
var corsMethods = configuration.GetValue<string>("Cors:AllowedMethods")
?? throw new InvalidOperationException("Cors:AllowedMethods configuration is required. Set it in appsettings.json or via CORS__ALLOWEDMETHODS environment variable.");
var corsHeaders = configuration.GetValue<string>("Cors:AllowedHeaders")
?? throw new InvalidOperationException("Cors:AllowedHeaders configuration is required. Set it in appsettings.json or via CORS__ALLOWEDHEADERS environment variable.");
// For bool, check if the key exists in configuration (GetValue<bool> returns false if not found, which is ambiguous)
var corsAllowCredentialsKey = configuration["Cors:AllowCredentials"];
if (corsAllowCredentialsKey == null)
{
throw new InvalidOperationException("Cors:AllowCredentials configuration is required. Set it in appsettings.json or via CORS__ALLOWCREDENTIALS environment variable.");
}
var corsAllowCredentials = configuration.GetValue<bool>("Cors:AllowCredentials");
services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
if (corsOrigins == "*")
{
policy.AllowAnyOrigin();
}
else
{
// Split comma-separated origins
var origins = corsOrigins.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
policy.WithOrigins(origins);
}
// Split comma-separated methods
var methods = corsMethods.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
policy.WithMethods(methods);
// Split comma-separated headers or allow all
if (corsHeaders == "*")
{
policy.AllowAnyHeader();
}
else
{
var headers = corsHeaders.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
policy.WithHeaders(headers);
}
if (corsAllowCredentials)
{
policy.AllowCredentials();
}
});
});
return services;
}
/// <summary>
/// Scans the assembly for services implementing registration interfaces and registers them automatically.
/// Services are registered as their concrete interface (that inherits from registration interface).
/// Properly handles generic interfaces by using concrete interfaces that hide the generics.
/// </summary>
public static IServiceCollection ScanAndRegisterServices(this IServiceCollection services, Assembly assembly)
{
var registeredTypes = new HashSet<Type>(); // Track registered types to avoid duplicates
// Get all types from the assembly with improved filtering
var types = GetRegisterableTypes(assembly);
// Register Singleton services
RegisterServicesByLifetime<ISingletonService>(
services,
types,
registeredTypes);
// Register Scoped services
RegisterServicesByLifetime<IScopedService>(
services,
types,
registeredTypes);
// Register Transient services
RegisterServicesByLifetime<ITransientService>(
services,
types,
registeredTypes);
// Register Hosted Services
RegisterHostedServices(services, types, registeredTypes);
return services;
}
/// <summary>
/// Gets all types that should be considered for registration.
/// Filters out abstract classes, interfaces, generic type definitions, nested private types,
/// compiler-generated types, and types from excluded namespaces.
/// </summary>
private static List<Type> GetRegisterableTypes(Assembly assembly)
{
return assembly.GetTypes()
.Where(t =>
// Must be a class (not interface, struct, enum, etc.)
t.IsClass
// Must be concrete (not abstract)
&& !t.IsAbstract
// Must not be a generic type definition (but closed generics are OK)
&& !t.IsGenericTypeDefinition
// Must be public (or nested public in a public type)
&& (t.IsPublic || (t.IsNestedPublic && t.DeclaringType?.IsPublic == true))
// Must not be compiler-generated (e.g., async state machines, iterator classes)
&& !t.IsDefined(typeof(System.Runtime.CompilerServices.CompilerGeneratedAttribute), inherit: false)
// Must not be a nested private/internal type
&& !(t.IsNested && !t.IsNestedPublic)
// Exclude test namespaces if any (optional - adjust as needed)
&& !t.Namespace?.StartsWith("BomLocalService.Tests", StringComparison.Ordinal) == true
)
.ToList();
}
private static void RegisterServicesByLifetime<TRegistrationInterface>(
IServiceCollection services,
List<Type> types,
HashSet<Type> registeredTypes)
where TRegistrationInterface : class
{
foreach (var implementationType in types)
{
// Skip if already registered
if (registeredTypes.Contains(implementationType))
continue;
// Get all interfaces implemented by this type
var allInterfaces = implementationType.GetInterfaces().ToList();
// Find interfaces that inherit from TRegistrationInterface (but not the registration interface itself)
var registrationInterfaces = allInterfaces
.Where(i => typeof(TRegistrationInterface).IsAssignableFrom(i)
&& i != typeof(TRegistrationInterface)
&& !IsGenericTypeDefinition(i))
.ToList();
if (registrationInterfaces.Count == 0)
continue;
// For each registration interface, find the most specific concrete interface
// Priority: concrete interfaces (like IRadarScrapingWorkflow) over generic interfaces (like IWorkflow<T>)
foreach (var registrationInterface in registrationInterfaces)
{
// Skip if this is a generic interface definition (we prefer concrete interfaces)
if (registrationInterface.IsGenericTypeDefinition)
continue;
// Check if this interface is already registered to avoid duplicates
var alreadyRegistered = services.Any(s =>
s.ServiceType == registrationInterface &&
s.ImplementationType == implementationType);
if (alreadyRegistered)
continue;
// Determine lifetime based on registration interface
var lifetime = GetLifetime<TRegistrationInterface>();
// Register the service by interface
if (lifetime == ServiceLifetime.Singleton)
{
services.AddSingleton(registrationInterface, implementationType);
// Also register as concrete type for direct resolution (e.g., for ScrapingStepRegistry)
services.AddSingleton(implementationType);
}
else if (lifetime == ServiceLifetime.Scoped)
{
services.AddScoped(registrationInterface, implementationType);
services.AddScoped(implementationType);
}
else if (lifetime == ServiceLifetime.Transient)
{
services.AddTransient(registrationInterface, implementationType);
services.AddTransient(implementationType);
}
registeredTypes.Add(implementationType);
}
}
}
private static bool IsGenericTypeDefinition(Type type)
{
return type.IsGenericTypeDefinition ||
(type.IsGenericType && type.GetGenericTypeDefinition() == type);
}
private static ServiceLifetime GetLifetime<TRegistrationInterface>()
where TRegistrationInterface : class
{
if (typeof(ISingletonService).IsAssignableFrom(typeof(TRegistrationInterface)))
return ServiceLifetime.Singleton;
if (typeof(IScopedService).IsAssignableFrom(typeof(TRegistrationInterface)))
return ServiceLifetime.Scoped;
if (typeof(ITransientService).IsAssignableFrom(typeof(TRegistrationInterface)))
return ServiceLifetime.Transient;
throw new InvalidOperationException($"Unknown registration interface: {typeof(TRegistrationInterface).Name}");
}
private static void RegisterHostedServices(
IServiceCollection services,
List<Type> types,
HashSet<Type> registeredTypes)
{
foreach (var type in types)
{
// Skip if already registered
if (registeredTypes.Contains(type))
continue;
// Check if it's a BackgroundService that implements IHostedServiceRegistration
if (typeof(Microsoft.Extensions.Hosting.BackgroundService).IsAssignableFrom(type) &&
typeof(IHostedServiceRegistration).IsAssignableFrom(type))
{
// Check if already registered
var alreadyRegistered = services.Any(s =>
s.ServiceType == typeof(Microsoft.Extensions.Hosting.IHostedService) &&
s.ImplementationType == type);
if (!alreadyRegistered)
{
// Use AddHostedService via reflection - this is the recommended way
// It properly handles lifecycle management for hosted services
// AddHostedService<T> is generic, so we need to call it via reflection
var addHostedServiceMethod = typeof(Microsoft.Extensions.DependencyInjection.ServiceCollectionHostedServiceExtensions)
.GetMethod(nameof(Microsoft.Extensions.DependencyInjection.ServiceCollectionHostedServiceExtensions.AddHostedService),
new[] { typeof(IServiceCollection) })!
.MakeGenericMethod(type);
addHostedServiceMethod.Invoke(null, new object[] { services });
registeredTypes.Add(type);
}
}
}
}
}
+3 -2
View File
@@ -11,8 +11,9 @@ public class FrameMetadata
public int FrameIndex { get; set; }
/// <summary>
/// Number of minutes ago this frame represents (40, 35, 30, 25, 20, 15, 10).
/// The absolute UTC observation time for this frame.
/// Parsed directly from the frame display label during capture.
/// </summary>
public int MinutesAgo { get; set; }
public DateTime ObservationTime { get; set; }
}
+2 -1
View File
@@ -53,6 +53,7 @@ public class JavaScriptTemplatesConfig
public string CheckActiveFrameSegment { get; set; } = @"() => {
const segments = Array.from(document.querySelectorAll('[data-testid=""bom-scrub-segment""]'));
if (segments.length === 0) return true;
const activeSegment = segments.find(s => {
const style = window.getComputedStyle(s);
return style.backgroundColor !== 'rgb(148, 148, 148)' && style.backgroundColor !== 'rgb(148, 148, 148)';
@@ -73,7 +74,7 @@ public class JavaScriptTemplatesConfig
let minutes = null;
// Parse timestamp format: ""Wednesday 17 Dec, 11:05 pm"" or ""17 Dec, 11:05 pm""
const timestampMatch = ariaLabel.match(/(?:[A-Za-z]+\s+)?(\d{1,2})\s+([A-Za-z]{3}),?\s+(\d{1,2}):(\d{2})\s+(am|pm)/i);
const timestampMatch = ariaLabel.match(/(?:[A-Za-z]+\s+)?(\d{1,2})\s+([A-Za-z]+),?\s+(\d{1,2}):(\d{2})\s*(am|pm)/i);
if (timestampMatch) {
try {
const day = parseInt(timestampMatch[1]);
+2 -10
View File
@@ -2,22 +2,14 @@ namespace BomLocalService.Models;
/// <summary>
/// Represents a single radar frame (historical precipitation data).
/// Frame 0 is oldest (40 minutes ago), Frame 6 is newest (10 minutes ago).
/// Radar frames can be joined across cache folders to create extended historical slideshows.
/// </summary>
public class RadarFrame : CachedFrame
{
/// <summary>
/// Number of minutes ago this frame represents (40, 35, 30, 25, 20, 15, 10).
/// Frame 0 = 40 minutes ago, Frame 6 = 10 minutes ago.
/// This is relative to the cache folder's observation time.
/// </summary>
public int MinutesAgo { get; set; }
/// <summary>
/// The absolute UTC observation time for this frame.
/// Calculated as: ObservationTime - MinutesAgo.
/// This is set when frames are joined across cache folders in timeseries responses.
/// Parsed directly from the frame display label during capture.
/// Client calculates "minutes ago" dynamically from this timestamp.
/// </summary>
public DateTime? AbsoluteObservationTime { get; set; }
+2 -1
View File
@@ -8,7 +8,8 @@ public class RadarResponse
{
/// <summary>
/// List of all captured frames (typically 7 frames: 0-6).
/// Frame 0 is oldest (40 minutes ago), Frame 6 is newest (10 minutes ago).
/// Each frame contains an absoluteObservationTime UTC timestamp.
/// Client should calculate "minutes ago" dynamically from the timestamp.
/// </summary>
public List<RadarFrame> Frames { get; set; } = new();
+1 -1
View File
@@ -6,7 +6,7 @@ namespace BomLocalService.Models;
public class TextPatternsConfig
{
public string ResultsCountPattern { get; set; } = @"(\d+)\s+of\s+(\d+)";
public string TimestampPattern { get; set; } = @"(?:[A-Za-z]+\s+)?\d{1,2}\s+[A-Za-z]{3},?\s+\d{1,2}:\d{2}\s+(?:am|pm)";
public string TimestampPattern { get; set; } = @"(?:[A-Za-z]+\s+)?\d{1,2}\s+[A-Za-z]+,?\s+\d{1,2}:\d{2}\s*(?:am|pm)";
public string ObservationTimePattern { get; set; } = @"Observations:\s*(\d+)\s*minutes?\s*ago";
public string ForecastTimePattern { get; set; } = @"Forecast:\s*(\d+)\s*minutes?\s+ago";
public string ForecastHourAgoPattern { get; set; } = @"Forecast:\s*an\s+hour\s+ago";
+9 -146
View File
@@ -1,12 +1,5 @@
using BomLocalService.Services;
using BomLocalService.Extensions;
using BomLocalService.Services.Interfaces;
using BomLocalService.Services.Scraping;
using BomLocalService.Services.Scraping.Steps.Navigation;
using BomLocalService.Services.Scraping.Steps.Search;
using BomLocalService.Services.Scraping.Steps.Map;
using BomLocalService.Services.Scraping.Steps.Metadata;
using BomLocalService.Services.Scraping.Steps.Capture;
using BomLocalService.Services.Scraping.Workflows;
var builder = WebApplication.CreateBuilder(args);
@@ -14,135 +7,20 @@ var builder = WebApplication.CreateBuilder(args);
// AddControllersWithViews includes AddControllers, so we use it for both MVC and API controllers
builder.Services.AddControllersWithViews();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApi();
builder.Services.AddHealthChecks();
// Configure CORS - MUST be added before other services
var corsOrigins = builder.Configuration.GetValue<string>("Cors:AllowedOrigins", "*");
var corsMethods = builder.Configuration.GetValue<string>("Cors:AllowedMethods", "GET,POST,OPTIONS");
var corsHeaders = builder.Configuration.GetValue<string>("Cors:AllowedHeaders", "*");
var corsAllowCredentials = builder.Configuration.GetValue<bool>("Cors:AllowCredentials", false);
builder.Services.AddCorsConfiguration(builder.Configuration);
// Support environment variable override (comma-separated for multiple origins)
var corsOriginsEnv = Environment.GetEnvironmentVariable("CORS__ALLOWEDORIGINS");
if (!string.IsNullOrEmpty(corsOriginsEnv))
{
corsOrigins = corsOriginsEnv;
}
var corsMethodsEnv = Environment.GetEnvironmentVariable("CORS__ALLOWEDMETHODS");
if (!string.IsNullOrEmpty(corsMethodsEnv))
{
corsMethods = corsMethodsEnv;
}
var corsHeadersEnv = Environment.GetEnvironmentVariable("CORS__ALLOWEDHEADERS");
if (!string.IsNullOrEmpty(corsHeadersEnv))
{
corsHeaders = corsHeadersEnv;
}
var corsAllowCredentialsEnv = Environment.GetEnvironmentVariable("CORS__ALLOWCREDENTIALS");
if (!string.IsNullOrEmpty(corsAllowCredentialsEnv) && bool.TryParse(corsAllowCredentialsEnv, out var parsedCredentials))
{
corsAllowCredentials = parsedCredentials;
}
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
if (corsOrigins == "*")
{
policy.AllowAnyOrigin();
}
else
{
// Split comma-separated origins
var origins = corsOrigins.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
policy.WithOrigins(origins);
}
// Split comma-separated methods
var methods = corsMethods.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
policy.WithMethods(methods);
// Split comma-separated headers or allow all
if (corsHeaders == "*")
{
policy.AllowAnyHeader();
}
else
{
var headers = corsHeaders.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
policy.WithHeaders(headers);
}
if (corsAllowCredentials)
{
policy.AllowCredentials();
}
});
});
// Register core services via interfaces (order matters - dependencies must be registered first)
builder.Services.AddSingleton<IDebugService, DebugService>();
builder.Services.AddSingleton<ICacheService, CacheService>();
builder.Services.AddSingleton<ITimeParsingService, TimeParsingService>();
builder.Services.AddSingleton<IBrowserService, BrowserService>();
builder.Services.AddSingleton<ISelectorService, SelectorService>();
// Register scraping step registry
builder.Services.AddSingleton<IScrapingStepRegistry, ScrapingStepRegistry>();
// Register all scraping steps
builder.Services.AddScoped<NavigateHomepageStep>();
builder.Services.AddScoped<ClickSearchButtonStep>();
builder.Services.AddScoped<FillSearchInputStep>();
builder.Services.AddScoped<WaitForSearchResultsStep>();
builder.Services.AddScoped<SelectSearchResultStep>();
builder.Services.AddScoped<ClickRadarLinkStep>();
builder.Services.AddScoped<WaitForMapReadyStep>();
builder.Services.AddScoped<PauseRadarStep>();
builder.Services.AddScoped<ResetToFirstFrameStep>();
builder.Services.AddScoped<ExtractMetadataStep>();
builder.Services.AddScoped<CalculateMapBoundsStep>();
builder.Services.AddScoped<CaptureFramesStep>();
// Register workflows
builder.Services.AddScoped<RadarScrapingWorkflow>();
builder.Services.AddScoped<TemperatureMapWorkflow>();
// Register workflow factory
builder.Services.AddSingleton<IWorkflowFactory, WorkflowFactory>();
// Register scraping service (depends on workflow factory)
builder.Services.AddSingleton<IScrapingService, ScrapingService>();
// Register BOM Radar Service as singleton (orchestrator, depends on all above services)
builder.Services.AddSingleton<IBomRadarService, BomLocalService.Services.BomRadarService>();
// Register background services (order matters - management service needs radar service)
builder.Services.AddHostedService<CacheCleanupService>();
builder.Services.AddHostedService<CacheManagementService>();
// Add configuration - appsettings.json provides default values
// All values can be overridden via environment variables
builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
// Register all services via assembly scanning
builder.Services.ScanAndRegisterServices(typeof(Program).Assembly);
var app = builder.Build();
// Configure the HTTP request pipeline
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
// HTTPS redirection is optional and disabled by default for Docker flexibility
// Users can enable it by setting ENABLE_HTTPS_REDIRECTION=true environment variable
// or EnableHttpsRedirection=true in appsettings.json
var enableHttpsRedirection = builder.Configuration.GetValue<bool>("EnableHttpsRedirection", false) ||
Environment.GetEnvironmentVariable("ENABLE_HTTPS_REDIRECTION")?.Equals("true", StringComparison.OrdinalIgnoreCase) == true;
// Users can enable it by setting EnableHttpsRedirection=true in appsettings.json or ENABLEHTTPSREDIRECTION=true environment variable
var enableHttpsRedirection = builder.Configuration.GetValue<bool>("EnableHttpsRedirection", false);
if (enableHttpsRedirection)
{
app.UseHttpsRedirection();
@@ -151,6 +29,9 @@ if (enableHttpsRedirection)
// Enable routing (required for CORS to work with controllers)
app.UseRouting();
// Static files (for wwwroot assets like CSS, JS, images)
app.UseStaticFiles();
// CORS middleware - MUST be after UseRouting but before MapControllers
app.UseCors();
@@ -167,24 +48,6 @@ app.MapControllers();
// Map health check endpoint for Docker health monitoring
app.MapHealthChecks("/api/health");
// Auto-register all scraping steps in the registry
var stepRegistry = app.Services.GetRequiredService<IScrapingStepRegistry>();
var stepTypes = typeof(IScrapingStep).Assembly.GetTypes()
.Where(t => typeof(IScrapingStep).IsAssignableFrom(t) && !t.IsInterface && !t.IsAbstract && !t.IsGenericType);
foreach (var stepType in stepTypes)
{
try
{
var step = (IScrapingStep)ActivatorUtilities.CreateInstance(app.Services, stepType);
stepRegistry.RegisterStep(step);
}
catch (Exception ex)
{
var logger = app.Services.GetRequiredService<ILogger<Program>>();
logger.LogWarning(ex, "Failed to register step {StepType}", stepType.Name);
}
}
// Cleanup incomplete cache folders from previous crashes/restarts before starting services
var cacheService = app.Services.GetRequiredService<ICacheService>();
var deletedCount = cacheService.CleanupIncompleteCacheFolders();
+10 -21
View File
@@ -1,9 +1,5 @@
<p align="center">
<img
src="https://github.com/user-attachments/assets/684b73cd-a374-49e6-aaf1-ccc4c5697b47"
width="500"
alt="f67325fe-33ae-4fd8-9e57-38b46899d09c"
/>
<img width="256" height="auto" alt="icon" src="https://github.com/user-attachments/assets/6f712fbd-4f42-49d4-9ddb-1767bb43551a" />
</p>
# BOM Local Service
@@ -218,7 +214,7 @@ All configuration can be done via environment variables, which override the defa
|----------|-------------|---------|
| `ASPNETCORE_ENVIRONMENT` | Runtime environment (Development/Production) | `Production` |
| `ASPNETCORE_URLS` | URLs the service listens on | `http://+:8080` |
| `ENABLE_HTTPS_REDIRECTION` | Enable HTTPS redirection | `false` |
| `ENABLEHTTPSREDIRECTION` | Enable HTTPS redirection | `false` |
#### Application Configuration
@@ -235,8 +231,7 @@ All configuration can be done via environment variables, which override the defa
|----------|-------------|---------|---------|
| `CACHEMANAGEMENT__CHECKINTERVALMINUTES` | Interval between cache validity checks | `5` | `10` |
| `CACHEMANAGEMENT__INITIALDELAYSECONDS` | Delay before first cache check on startup | `10` | `30` |
| `CACHEMANAGEMENT__UPDATESTAGGERSECONDS` | Delay between triggering cache updates | `2` | `5` |
| `CACHEMANAGEMENT__LOCATIONSTAGGERSECONDS` | Delay between processing different locations | `1` | `2` |
| `CACHEMANAGEMENT__LOCATIONSTAGGERSECONDS` | Delay between processing different locations (used for both initial and periodic updates) | `1` | `2` |
#### Cache Cleanup
@@ -524,8 +519,7 @@ GET /api/radar/{suburb}/{state}
{
"frameIndex": 0,
"imageUrl": "/api/radar/Brisbane/QLD/frame/0",
"minutesAgo": 0,
"observationTime": "2025-01-15T10:00:00Z"
"absoluteObservationTime": "2025-01-15T10:00:00Z"
}
],
"observationTime": "2025-01-15T10:00:00Z",
@@ -540,7 +534,7 @@ GET /api/radar/{suburb}/{state}
```
**Response Fields:**
- `frames`: Array of radar frame objects with image URLs and timing information
- `frames`: Array of radar frame objects with image URLs. Each frame contains `absoluteObservationTime` (UTC timestamp). Client should calculate "minutes ago" dynamically from this timestamp.
- `observationTime`: UTC timestamp when the observation was made
- `forecastTime`: UTC timestamp for the forecast
- `weatherStation`: Name of the weather station
@@ -670,8 +664,7 @@ GET /api/radar/{suburb}/{state}/timeseries?startTime={iso8601}&endTime={iso8601}
{
"frameIndex": 0,
"imageUrl": "/api/radar/Brisbane/QLD/frame/0?cacheFolder=Brisbane_QLD_20250115_100000",
"absoluteObservationTime": "2025-01-15T10:00:00Z",
"minutesAgo": 0
"absoluteObservationTime": "2025-01-15T10:00:00Z"
}
]
}
@@ -880,13 +873,6 @@ DELETE /api/cache/{suburb}/{state}
}
```
### OpenAPI Documentation
When running in Development mode, OpenAPI documentation is available at:
```
http://localhost:8082/openapi/v1.json
```
## Cache Update Estimation
The service uses a **metrics-based estimation system** to provide accurate estimates of cache update completion times. This ensures clients receive meaningful `nextUpdateTime` values that adapt to the actual hardware performance.
@@ -1016,7 +1002,10 @@ if (radarData.frames && radarData.frames.length > 0) {
// Or loop through all frames for animation
radarData.frames.forEach((frame, index) => {
console.log(`Frame ${index}: ${frame.imageUrl} (${frame.minutesAgo} min ago)`);
const minutesAgo = frame.absoluteObservationTime
? Math.round((Date.now() - new Date(frame.absoluteObservationTime).getTime()) / 60000)
: null;
console.log(`Frame ${index}: ${frame.imageUrl}${minutesAgo !== null ? ` (${minutesAgo} min ago)` : ''}`);
});
}
```
+47 -22
View File
@@ -32,9 +32,27 @@ public class BomRadarService : IBomRadarService, IDisposable
_scrapingService = scrapingService;
_debugService = debugService;
_configuration = configuration;
_cacheExpirationMinutes = configuration.GetValue<double>("CacheExpirationMinutes", 12.5);
_cacheManagementCheckIntervalMinutes = configuration.GetValue<int>("CacheManagement:CheckIntervalMinutes", 5);
_timeSeriesWarningFolderCount = configuration.GetValue<int>("TimeSeries:WarningFolderCount", 200);
var cacheExpirationMinutesConfig = configuration.GetValue<double?>("CacheExpirationMinutes");
if (!cacheExpirationMinutesConfig.HasValue)
{
throw new InvalidOperationException("CacheExpirationMinutes configuration is required. Set it in appsettings.json or via CACHEEXPIRATIONMINUTES environment variable.");
}
_cacheExpirationMinutes = cacheExpirationMinutesConfig.Value;
var cacheManagementCheckIntervalMinutesConfig = configuration.GetValue<int?>("CacheManagement:CheckIntervalMinutes");
if (!cacheManagementCheckIntervalMinutesConfig.HasValue)
{
throw new InvalidOperationException("CacheManagement:CheckIntervalMinutes configuration is required. Set it in appsettings.json or via CACHEMANAGEMENT__CHECKINTERVALMINUTES environment variable.");
}
_cacheManagementCheckIntervalMinutes = cacheManagementCheckIntervalMinutesConfig.Value;
var timeSeriesWarningFolderCountConfig = configuration.GetValue<int?>("TimeSeries:WarningFolderCount");
if (!timeSeriesWarningFolderCountConfig.HasValue)
{
throw new InvalidOperationException("TimeSeries:WarningFolderCount configuration is required. Set it in appsettings.json or via TIMESERIES__WARNINGFOLDERCOUNT environment variable.");
}
_timeSeriesWarningFolderCount = timeSeriesWarningFolderCountConfig.Value;
// Calculate estimated cache update duration
_estimatedUpdateDurationSeconds = CacheHelper.GetEstimatedUpdateDurationSeconds(configuration, CachedDataType.Radar);
@@ -167,6 +185,9 @@ public class BomRadarService : IBomRadarService, IDisposable
if (needsUpdate)
{
// Trigger async update (fire and forget)
// Do not pass the caller's cancellationToken to Task.Run: for HTTP that token is tied to
// RequestAborted and often fires as soon as the response is sent, so the update never starts.
// FetchAndCacheScreenshotAsync already uses CancellationToken.None for the scrape work.
_ = Task.Run(async () =>
{
try
@@ -177,7 +198,7 @@ public class BomRadarService : IBomRadarService, IDisposable
{
_logger.LogError(ex, "Error during background cache update for {Suburb}, {State}", suburb, state);
}
}, cancellationToken);
});
status.UpdateTriggered = true;
status.Message = status.CacheExists
@@ -388,7 +409,16 @@ public class BomRadarService : IBomRadarService, IDisposable
}
}
semaphore.Release();
// Safely release semaphore - it may be disposed during application shutdown
try
{
semaphore.Release();
}
catch (ObjectDisposedException)
{
// Semaphore was disposed during shutdown, ignore
_logger.LogDebug("Semaphore was disposed, skipping release");
}
}
}
@@ -501,32 +531,27 @@ public class BomRadarService : IBomRadarService, IDisposable
if (radarFrames.Count > 0)
{
// Generate URLs and calculate absolute observation times for each frame
// Generate URLs and filter frames by unique absolute observation times
var uniqueFrames = new List<RadarFrame>();
foreach (var frame in radarFrames)
{
frame.ImageUrl = $"/api/radar/{encodedSuburb}/{encodedState}/frame/{frame.FrameIndex}?cacheFolder={Uri.EscapeDataString(folderInfo.FolderName)}";
// Calculate absolute observation time for this frame
// minutesAgo is relative to the cache folder's observation time
// Validate that ObservationTime is reasonable (not default/min value)
if (folderInfo.ObservationTime > DateTime.MinValue.AddYears(1) && frame.MinutesAgo >= 0)
// Use absolute observation time directly (already set during capture)
// Only include frames with unique absolute observation times
// Since we process folders newest-first, this ensures we keep frames from the most recent cache folder
if (frame.AbsoluteObservationTime.HasValue &&
frame.AbsoluteObservationTime.Value > DateTime.MinValue.AddYears(1) &&
!seenAbsoluteTimes.Contains(frame.AbsoluteObservationTime.Value))
{
frame.AbsoluteObservationTime = folderInfo.ObservationTime.AddMinutes(-frame.MinutesAgo);
// Only include frames with unique absolute observation times
// Since we process folders newest-first, this ensures we keep frames from the most recent cache folder
if (frame.AbsoluteObservationTime.HasValue && !seenAbsoluteTimes.Contains(frame.AbsoluteObservationTime.Value))
{
seenAbsoluteTimes.Add(frame.AbsoluteObservationTime.Value);
uniqueFrames.Add(frame);
}
seenAbsoluteTimes.Add(frame.AbsoluteObservationTime.Value);
uniqueFrames.Add(frame);
}
else
else if (!frame.AbsoluteObservationTime.HasValue)
{
_logger.LogWarning("Skipping frame {FrameIndex} from folder {FolderName}: Invalid ObservationTime ({ObservationTime}) or MinutesAgo ({MinutesAgo})",
frame.FrameIndex, folderInfo.FolderName, folderInfo.ObservationTime, frame.MinutesAgo);
_logger.LogWarning("Skipping frame {FrameIndex} from folder {FolderName}: Missing AbsoluteObservationTime",
frame.FrameIndex, folderInfo.FolderName);
}
}
+9 -1
View File
@@ -15,7 +15,15 @@ public class BrowserService : IBrowserService
public BrowserService(ILogger<BrowserService> logger, IConfiguration configuration, IDebugService debugService)
{
_logger = logger;
_timezone = configuration.GetValue<string>("Timezone") ?? "Australia/Brisbane";
// Get timezone from configuration (default from appsettings.json, can be overridden via TIMEZONE environment variable)
var timezone = configuration.GetValue<string>("Timezone");
if (string.IsNullOrEmpty(timezone))
{
throw new InvalidOperationException("Timezone configuration is required. Set it in appsettings.json or via TIMEZONE environment variable.");
}
_timezone = timezone;
_debugService = debugService;
}
+16 -3
View File
@@ -1,9 +1,10 @@
using BomLocalService.Services.Interfaces.Registration;
using BomLocalService.Utilities;
using Microsoft.Extensions.Hosting;
namespace BomLocalService.Services;
public class CacheCleanupService : BackgroundService
public class CacheCleanupService : BackgroundService, IHostedServiceRegistration
{
private readonly ILogger<CacheCleanupService> _logger;
private readonly string _cacheDirectory;
@@ -14,8 +15,20 @@ public class CacheCleanupService : BackgroundService
{
_logger = logger;
_cacheDirectory = FilePathHelper.GetCacheDirectory(configuration);
_retentionHours = configuration.GetValue<int>("CacheRetentionHours", 24);
var cleanupIntervalHours = configuration.GetValue<int>("CacheCleanup:IntervalHours", 1);
var retentionHoursConfig = configuration.GetValue<int?>("CacheRetentionHours");
if (!retentionHoursConfig.HasValue)
{
throw new InvalidOperationException("CacheRetentionHours configuration is required. Set it in appsettings.json or via CACHERETENTIONHOURS environment variable.");
}
_retentionHours = retentionHoursConfig.Value;
var cleanupIntervalHoursConfig = configuration.GetValue<int?>("CacheCleanup:IntervalHours");
if (!cleanupIntervalHoursConfig.HasValue)
{
throw new InvalidOperationException("CacheCleanup:IntervalHours configuration is required. Set it in appsettings.json or via CACHECLEANUP__INTERVALHOURS environment variable.");
}
var cleanupIntervalHours = cleanupIntervalHoursConfig.Value;
_cleanupInterval = TimeSpan.FromHours(cleanupIntervalHours);
}
+61 -49
View File
@@ -1,16 +1,19 @@
using BomLocalService.Services.Interfaces;
using BomLocalService.Services.Interfaces.Registration;
using BomLocalService.Utilities;
using Microsoft.Extensions.Hosting;
namespace BomLocalService.Services;
public class CacheManagementService : BackgroundService
public class CacheManagementService : BackgroundService, IHostedServiceRegistration
{
private readonly ILogger<CacheManagementService> _logger;
private readonly IBomRadarService _bomRadarService;
private readonly IBrowserService _browserService;
private readonly IConfiguration _configuration;
private readonly TimeSpan _checkInterval;
private readonly TimeSpan _locationStaggerInterval;
private readonly TimeSpan _initialDelayInterval;
private readonly HashSet<string> _activeUpdates = new();
private readonly object _lock = new();
@@ -24,14 +27,34 @@ public class CacheManagementService : BackgroundService
_bomRadarService = bomRadarService;
_browserService = browserService;
_configuration = configuration;
var checkIntervalMinutes = configuration.GetValue<int>("CacheManagement:CheckIntervalMinutes", 5);
var checkIntervalMinutesConfig = configuration.GetValue<int?>("CacheManagement:CheckIntervalMinutes");
if (!checkIntervalMinutesConfig.HasValue)
{
throw new InvalidOperationException("CacheManagement:CheckIntervalMinutes configuration is required. Set it in appsettings.json or via CACHEMANAGEMENT__CHECKINTERVALMINUTES environment variable.");
}
var checkIntervalMinutes = checkIntervalMinutesConfig.Value;
if (checkIntervalMinutes <= 0 || checkIntervalMinutes > 60)
{
throw new ArgumentException("CacheManagement:CheckIntervalMinutes must be between 1 and 60", nameof(configuration));
}
_checkInterval = TimeSpan.FromMinutes(checkIntervalMinutes);
// LocationStaggerSeconds is used for delays between processing locations (both initial and periodic)
var locationStaggerSecondsConfig = configuration.GetValue<int?>("CacheManagement:LocationStaggerSeconds");
if (!locationStaggerSecondsConfig.HasValue)
{
throw new InvalidOperationException("CacheManagement:LocationStaggerSeconds configuration is required. Set it in appsettings.json or via CACHEMANAGEMENT__LOCATIONSTAGGERSECONDS environment variable.");
}
_locationStaggerInterval = TimeSpan.FromSeconds(locationStaggerSecondsConfig.Value);
var initialDelaySecondsConfig = configuration.GetValue<int?>("CacheManagement:InitialDelaySeconds");
if (!initialDelaySecondsConfig.HasValue)
{
throw new InvalidOperationException("CacheManagement:InitialDelaySeconds configuration is required. Set it in appsettings.json or via CACHEMANAGEMENT__INITIALDELAYSECONDS environment variable.");
}
_initialDelayInterval = TimeSpan.FromSeconds(initialDelaySecondsConfig.Value);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -39,8 +62,7 @@ public class CacheManagementService : BackgroundService
_logger.LogInformation("Cache management service started. Check interval: {Interval}", _checkInterval);
// Wait a bit for the service to fully initialize
var initialDelaySeconds = _configuration.GetValue<int>("CacheManagement:InitialDelaySeconds", 10);
await Task.Delay(TimeSpan.FromSeconds(initialDelaySeconds), stoppingToken);
await Task.Delay(_initialDelayInterval, stoppingToken);
// Pre-warm the browser before starting cache updates
_logger.LogInformation("Pre-warming browser before cache updates");
@@ -58,28 +80,7 @@ public class CacheManagementService : BackgroundService
// Initial cache update on startup
_logger.LogInformation("Performing initial cache update for {Count} locations", locationsToManage.Count);
int initialUpdatesTriggered = 0;
int initialCachesValid = 0;
foreach (var (suburb, state) in locationsToManage)
{
if (stoppingToken.IsCancellationRequested)
break;
var (triggered, isValid) = await UpdateCacheIfNeededAsync(suburb, state, stoppingToken);
if (triggered)
{
initialUpdatesTriggered++;
}
else if (isValid)
{
initialCachesValid++;
}
// Stagger updates to avoid overwhelming the system
var updateStaggerSeconds = _configuration.GetValue<int>("CacheManagement:UpdateStaggerSeconds", 2);
await Task.Delay(TimeSpan.FromSeconds(updateStaggerSeconds), stoppingToken);
}
var (initialUpdatesTriggered, initialCachesValid) = await ProcessLocationsAsync(locationsToManage, stoppingToken);
_logger.LogInformation("Initial cache update completed: {UpdatesTriggered} updates triggered, {CachesValid} caches valid",
initialUpdatesTriggered, initialCachesValid);
@@ -95,28 +96,7 @@ public class CacheManagementService : BackgroundService
locationsToManage = GetLocationsToManage();
_logger.LogInformation("Starting periodic cache check for {Count} locations", locationsToManage.Count);
int updatesTriggered = 0;
int cachesValid = 0;
foreach (var (suburb, state) in locationsToManage)
{
if (stoppingToken.IsCancellationRequested)
break;
var (triggered, isValid) = await UpdateCacheIfNeededAsync(suburb, state, stoppingToken);
if (triggered)
{
updatesTriggered++;
}
else if (isValid)
{
cachesValid++;
}
// Small delay between locations
var locationStaggerSeconds = _configuration.GetValue<int>("CacheManagement:LocationStaggerSeconds", 1);
await Task.Delay(TimeSpan.FromSeconds(locationStaggerSeconds), stoppingToken);
}
var (updatesTriggered, cachesValid) = await ProcessLocationsAsync(locationsToManage, stoppingToken);
_logger.LogInformation("Periodic cache check completed: {UpdatesTriggered} updates triggered, {CachesValid} caches valid",
updatesTriggered, cachesValid);
@@ -128,6 +108,38 @@ public class CacheManagementService : BackgroundService
}
}
/// <summary>
/// Processes a list of locations, checking and updating cache as needed
/// </summary>
private async Task<(int updatesTriggered, int cachesValid)> ProcessLocationsAsync(
List<(string suburb, string state)> locations,
CancellationToken cancellationToken)
{
int updatesTriggered = 0;
int cachesValid = 0;
foreach (var (suburb, state) in locations)
{
if (cancellationToken.IsCancellationRequested)
break;
var (triggered, isValid) = await UpdateCacheIfNeededAsync(suburb, state, cancellationToken);
if (triggered)
{
updatesTriggered++;
}
else if (isValid)
{
cachesValid++;
}
// Stagger processing to avoid overwhelming the system
await Task.Delay(_locationStaggerInterval, cancellationToken);
}
return (updatesTriggered, cachesValid);
}
private async Task<(bool updateTriggered, bool isValid)> UpdateCacheIfNeededAsync(string suburb, string state, CancellationToken cancellationToken)
{
var locationKey = LocationHelper.GetLocationKey(suburb, state);
+17 -12
View File
@@ -27,7 +27,13 @@ public class CacheService : ICacheService
_logger = logger;
_configuration = configuration;
_cacheDirectory = FilePathHelper.GetCacheDirectory(configuration);
_cacheExpirationMinutes = configuration.GetValue<double>("CacheExpirationMinutes", 12.5);
var cacheExpirationMinutesConfig = configuration.GetValue<double?>("CacheExpirationMinutes");
if (!cacheExpirationMinutesConfig.HasValue)
{
throw new InvalidOperationException("CacheExpirationMinutes configuration is required. Set it in appsettings.json or via CACHEEXPIRATIONMINUTES environment variable.");
}
_cacheExpirationMinutes = cacheExpirationMinutesConfig.Value;
if (_cacheExpirationMinutes <= 0)
{
@@ -148,18 +154,18 @@ public class CacheService : ICacheService
return null;
}
// Load frame metadata to get accurate minutesAgo
// Load frame metadata to get observation time
var framesMetadata = await LoadFramesMetadataAsync(cacheFolderPath, CachedDataType.Radar, cancellationToken);
var frameMetadata = framesMetadata.FirstOrDefault(f => f.FrameIndex == frameIndex);
var minutesAgo = frameMetadata != null
? frameMetadata.MinutesAgo
: 40 - (frameIndex * 5);
var observationTime = frameMetadata?.ObservationTime > DateTime.MinValue.AddYears(1)
? frameMetadata.ObservationTime
: (DateTime?)null;
return new RadarFrame
{
FrameIndex = frameIndex,
ImagePath = framePath,
MinutesAgo = minutesAgo
AbsoluteObservationTime = observationTime
};
}
@@ -194,7 +200,7 @@ public class CacheService : ICacheService
var framesMetadata = frames.Select(f => new FrameMetadata
{
FrameIndex = f.FrameIndex,
MinutesAgo = f.MinutesAgo
ObservationTime = f.AbsoluteObservationTime ?? throw new InvalidOperationException($"Frame {f.FrameIndex} missing AbsoluteObservationTime")
}).ToList();
var framesPath = FilePathHelper.GetFramesMetadataFilePath(cacheFolderPath, dataType);
@@ -418,7 +424,8 @@ public class CacheService : ICacheService
// Load frame metadata from frames.json if available
var framesMetadata = await LoadFramesMetadataAsync(folderPath, dataType, cancellationToken);
var metadataDict = framesMetadata.ToDictionary(f => f.FrameIndex, f => f.MinutesAgo);
var metadataDict = framesMetadata.Where(f => f.ObservationTime > DateTime.MinValue.AddYears(1))
.ToDictionary(f => f.FrameIndex, f => f.ObservationTime);
// Load frames from data type subfolder
for (int i = 0; i < frameCount; i++)
@@ -428,15 +435,13 @@ public class CacheService : ICacheService
{
if (dataType == CachedDataType.Radar)
{
var minutesAgo = metadataDict.ContainsKey(i)
? metadataDict[i]
: 40 - (i * 5);
var observationTime = metadataDict.ContainsKey(i) ? metadataDict[i] : (DateTime?)null;
frames.Add(new RadarFrame
{
FrameIndex = i,
ImagePath = framePath,
MinutesAgo = minutesAgo
AbsoluteObservationTime = observationTime
});
}
else
+10 -13
View File
@@ -14,20 +14,17 @@ namespace BomLocalService.Services;
public DebugService(ILogger<DebugService> logger, IConfiguration configuration)
{
_logger = logger;
_waitMs = configuration.GetValue<int>("Debug:WaitMs", 2000);
var waitMsConfig = configuration.GetValue<int?>("Debug:WaitMs");
if (!waitMsConfig.HasValue)
{
throw new InvalidOperationException("Debug:WaitMs configuration is required. Set it in appsettings.json or via DEBUG__WAITMS environment variable.");
}
_waitMs = waitMsConfig.Value;
// Check all possible ways the config might be set
var debugEnabledEnv = Environment.GetEnvironmentVariable("DEBUG__ENABLED");
var debugEnabledConfig = configuration.GetValue<bool>("Debug:Enabled", false);
var debugEnabledFromConfig = configuration["Debug:Enabled"];
_logger.LogInformation("Debug configuration check - Env var DEBUG__ENABLED: {EnvVar}, Config Debug:Enabled: {ConfigValue}, Config string: {ConfigString}",
debugEnabledEnv ?? "null", debugEnabledConfig, debugEnabledFromConfig ?? "null");
// Try environment variable first, then config
_enabled = !string.IsNullOrEmpty(debugEnabledEnv)
? bool.TryParse(debugEnabledEnv, out var envBool) && envBool
: debugEnabledConfig;
// Get debug enabled from configuration (default from appsettings.json, can be overridden via DEBUG__ENABLED environment variable)
// Note: bool defaults to false if not found, which is acceptable for Debug:Enabled
_enabled = configuration.GetValue<bool>("Debug:Enabled", false);
var cacheDirectory = FilePathHelper.GetCacheDirectory(configuration);
_debugDirectory = Path.Combine(cacheDirectory, "debug");
+2 -1
View File
@@ -1,4 +1,5 @@
using BomLocalService.Models;
using BomLocalService.Services.Interfaces.Registration;
namespace BomLocalService.Services.Interfaces;
@@ -6,7 +7,7 @@ namespace BomLocalService.Services.Interfaces;
/// Main service interface for BOM radar screenshot operations.
/// Orchestrates cache management, browser automation, and web scraping to provide radar screenshots for Australian locations.
/// </summary>
public interface IBomRadarService
public interface IBomRadarService : ISingletonService
{
/// <summary>
/// Gets cached radar data for a location.
+2 -1
View File
@@ -1,3 +1,4 @@
using BomLocalService.Services.Interfaces.Registration;
using Microsoft.Playwright;
namespace BomLocalService.Services.Interfaces;
@@ -6,7 +7,7 @@ namespace BomLocalService.Services.Interfaces;
/// Service interface for managing Playwright browser instances and automation.
/// Handles browser lifecycle, context creation, and anti-detection measures for web scraping.
/// </summary>
public interface IBrowserService : IDisposable
public interface IBrowserService : ISingletonService, IDisposable
{
/// <summary>
/// Creates a new browser context with proper configuration for BOM website scraping.
+3 -2
View File
@@ -1,4 +1,5 @@
using BomLocalService.Models;
using BomLocalService.Services.Interfaces.Registration;
namespace BomLocalService.Services.Interfaces;
@@ -6,7 +7,7 @@ namespace BomLocalService.Services.Interfaces;
/// Service interface for managing cached radar screenshot files and metadata.
/// Handles file system operations for storing and retrieving cached BOM radar screenshots.
/// </summary>
public interface ICacheService
public interface ICacheService : ISingletonService
{
/// <summary>
/// Gets the cached screenshot folder path and associated metadata for a location and data type.
@@ -90,7 +91,7 @@ public interface ICacheService
Task SaveMetadataAsync(string cacheFolderPath, LastUpdatedInfo metadata, CancellationToken cancellationToken = default);
/// <summary>
/// Saves frame metadata (frame index and minutes ago) to a frames.json file in the data type subfolder.
/// Saves frame metadata (frame index and observation time) to a frames.json file in the data type subfolder.
/// </summary>
/// <param name="cacheFolderPath">Full path to the cache folder</param>
/// <param name="dataType">The type of cached data</param>
+2 -1
View File
@@ -1,3 +1,4 @@
using BomLocalService.Services.Interfaces.Registration;
using Microsoft.Playwright;
namespace BomLocalService.Services.Interfaces;
@@ -6,7 +7,7 @@ namespace BomLocalService.Services.Interfaces;
/// Service interface for debug file generation during web scraping operations.
/// When enabled, saves screenshots, HTML, console logs, and network request information for troubleshooting.
/// </summary>
public interface IDebugService
public interface IDebugService : ISingletonService
{
/// <summary>
/// Indicates whether debug mode is enabled.
+2 -1
View File
@@ -1,4 +1,5 @@
using BomLocalService.Models;
using BomLocalService.Services.Interfaces.Registration;
using Microsoft.Playwright;
namespace BomLocalService.Services.Interfaces;
@@ -7,7 +8,7 @@ namespace BomLocalService.Services.Interfaces;
/// Service interface for scraping radar screenshots from the BOM website.
/// Orchestrates the multi-step process of navigating BOM, finding locations, and capturing radar screenshots.
/// </summary>
public interface IScrapingService
public interface IScrapingService : ISingletonService
{
/// <summary>
/// Scrapes a radar screenshot from the BOM website for a given location.
@@ -0,0 +1,27 @@
using BomLocalService.Services.Interfaces.Registration;
using BomLocalService.Services.Scraping;
using Microsoft.Playwright;
namespace BomLocalService.Services.Interfaces;
/// <summary>
/// Parses the BOM scrubber time display label to UTC; shared by capture and reset steps.
/// </summary>
public interface IScrubDisplayTimestampParser : ISingletonService
{
Task<DateTime?> ReadUtcAsync(
IPage page,
ISelectorService selectorService,
ScrapingContext? context = null,
CancellationToken cancellationToken = default);
bool TryParseTimestamp(string timestampStr, string? timezoneAbbreviation, out DateTime timestampUtc);
Task WaitForDisplayLabelChangeAsync(
IPage page,
ISelectorService selectorService,
DateTime currentTimestamp,
ScrapingContext? context = null,
int maxWaitMs = 5000,
CancellationToken cancellationToken = default);
}
+2 -1
View File
@@ -1,4 +1,5 @@
using BomLocalService.Models;
using BomLocalService.Services.Interfaces.Registration;
using Microsoft.Playwright;
namespace BomLocalService.Services.Interfaces;
@@ -6,7 +7,7 @@ namespace BomLocalService.Services.Interfaces;
/// <summary>
/// Service for finding page elements using configured selectors
/// </summary>
public interface ISelectorService
public interface ISelectorService : ISingletonService
{
/// <summary>
/// Finds an element using the configured selectors, trying each in order until one is found
+2 -1
View File
@@ -1,4 +1,5 @@
using BomLocalService.Models;
using BomLocalService.Services.Interfaces.Registration;
using Microsoft.Playwright;
namespace BomLocalService.Services.Interfaces;
@@ -7,7 +8,7 @@ namespace BomLocalService.Services.Interfaces;
/// Service interface for parsing time and metadata information from BOM website content.
/// Extracts observation times, forecast times, weather station names, and distances from BOM weather map pages.
/// </summary>
public interface ITimeParsingService
public interface ITimeParsingService : ISingletonService
{
/// <summary>
/// Extracts last updated information from a BOM weather map page.
@@ -0,0 +1,30 @@
namespace BomLocalService.Services.Interfaces.Registration;
/// <summary>
/// Marker interface for services that should be registered as Singleton
/// </summary>
public interface ISingletonService
{
}
/// <summary>
/// Marker interface for services that should be registered as Scoped
/// </summary>
public interface IScopedService
{
}
/// <summary>
/// Marker interface for services that should be registered as Transient
/// </summary>
public interface ITransientService
{
}
/// <summary>
/// Marker interface for hosted services (BackgroundService implementations)
/// </summary>
public interface IHostedServiceRegistration
{
}
+4 -1
View File
@@ -1,9 +1,12 @@
using BomLocalService.Services.Interfaces.Registration;
namespace BomLocalService.Services.Scraping;
/// <summary>
/// Interface for a single scraping step
/// Steps are stateless (all request data is in ScrapingContext), so singleton lifetime is appropriate
/// </summary>
public interface IScrapingStep
public interface IScrapingStep : ISingletonService
{
/// <summary>
/// Unique name of the step
+3 -1
View File
@@ -1,9 +1,11 @@
using BomLocalService.Services.Interfaces.Registration;
namespace BomLocalService.Services.Scraping;
/// <summary>
/// Registry for managing scraping steps
/// </summary>
public interface IScrapingStepRegistry
public interface IScrapingStepRegistry : ISingletonService
{
void RegisterStep(IScrapingStep step);
IScrapingStep? GetStep(string name);
+3 -1
View File
@@ -1,9 +1,11 @@
using BomLocalService.Services.Interfaces.Registration;
namespace BomLocalService.Services.Scraping;
/// <summary>
/// Factory for creating workflows
/// </summary>
public interface IWorkflowFactory
public interface IWorkflowFactory : ISingletonService
{
IWorkflow<TResponse> GetWorkflow<TResponse>(string name);
}
+27 -1
View File
@@ -1,3 +1,5 @@
using System.Reflection;
namespace BomLocalService.Services.Scraping;
public class ScrapingStepRegistry : IScrapingStepRegistry
@@ -5,9 +7,33 @@ public class ScrapingStepRegistry : IScrapingStepRegistry
private readonly Dictionary<string, IScrapingStep> _steps = new();
private readonly ILogger<ScrapingStepRegistry> _logger;
public ScrapingStepRegistry(ILogger<ScrapingStepRegistry> logger)
public ScrapingStepRegistry(ILogger<ScrapingStepRegistry> logger, IServiceProvider serviceProvider)
{
_logger = logger;
AutoRegisterSteps(serviceProvider);
}
private void AutoRegisterSteps(IServiceProvider serviceProvider)
{
var stepTypes = typeof(IScrapingStep).Assembly.GetTypes()
.Where(t => typeof(IScrapingStep).IsAssignableFrom(t)
&& !t.IsInterface
&& !t.IsAbstract
&& !t.IsGenericType);
foreach (var stepType in stepTypes)
{
try
{
// Use DI to resolve the step (ensures proper dependency injection)
var step = (IScrapingStep)serviceProvider.GetRequiredService(stepType);
RegisterStep(step);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to auto-register step {StepType}", stepType.Name);
}
}
}
public void RegisterStep(IScrapingStep step)
@@ -0,0 +1,215 @@
using System.Text.RegularExpressions;
using BomLocalService.Models;
using BomLocalService.Services.Interfaces;
using Microsoft.Playwright;
namespace BomLocalService.Services.Scraping;
/// <summary>
/// Parses the BOM scrubber time label (.bom-scrub-display-label) to UTC.
/// Shared by capture and reset steps so behaviour stays in sync.
/// </summary>
public class ScrubDisplayTimestampParser : IScrubDisplayTimestampParser
{
private readonly IConfiguration _configuration;
private readonly ScrapingSelectorsConfig _selectors;
private readonly TextPatternsConfig _textPatterns;
private readonly JavaScriptTemplatesConfig _javaScriptTemplates;
private readonly ILogger<ScrubDisplayTimestampParser> _logger;
public ScrubDisplayTimestampParser(
IConfiguration configuration,
ILogger<ScrubDisplayTimestampParser> logger)
{
_configuration = configuration;
_selectors = configuration.GetSection("Scraping:Selectors").Get<ScrapingSelectorsConfig>() ?? new();
_textPatterns = configuration.GetSection("Scraping:TextPatterns").Get<TextPatternsConfig>() ?? new();
_javaScriptTemplates = configuration.GetSection("Scraping:JavaScriptTemplates").Get<JavaScriptTemplatesConfig>() ?? new();
_logger = logger;
}
public async Task<DateTime?> ReadUtcAsync(
IPage page,
ISelectorService selectorService,
ScrapingContext? context = null,
CancellationToken cancellationToken = default)
{
_ = cancellationToken;
try
{
var timeLabelLocator = selectorService.GetLocator(page, _selectors.TimeDisplayLabel);
var timeLabel = await timeLabelLocator.TextContentAsync();
if (string.IsNullOrEmpty(timeLabel))
{
_logger.LogDebug("Time display label is empty");
return null;
}
var trimmedLabel = timeLabel.Trim();
_logger.LogDebug("Scrub display label: '{Label}'", trimmedLabel);
var timestampMatch = Regex.Match(trimmedLabel, _textPatterns.TimestampPattern, RegexOptions.IgnoreCase);
if (!timestampMatch.Success)
{
_logger.LogWarning(
"Display label did not match timestamp pattern. Label: '{Label}', Pattern: '{Pattern}'",
trimmedLabel,
_textPatterns.TimestampPattern);
return null;
}
var timestampStr = timestampMatch.Groups[0].Value;
string? detectedTimezone = null;
if (trimmedLabel.Contains("AEDT", StringComparison.OrdinalIgnoreCase))
detectedTimezone = "AEDT";
else if (trimmedLabel.Contains("AEST", StringComparison.OrdinalIgnoreCase))
detectedTimezone = "AEST";
if (detectedTimezone == null && context != null)
{
try
{
var metadataText = await page.EvaluateAsync<string>(_javaScriptTemplates.ExtractWeatherMetadata);
if (!string.IsNullOrEmpty(metadataText))
{
if (metadataText.Contains("AEDT", StringComparison.OrdinalIgnoreCase))
detectedTimezone = "AEDT";
else if (metadataText.Contains("AEST", StringComparison.OrdinalIgnoreCase))
detectedTimezone = "AEST";
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to extract timezone from metadata, using configured timezone");
}
}
if (TryParseTimestamp(timestampStr, detectedTimezone, out var frameTimestampUtc))
return frameTimestampUtc;
_logger.LogWarning("Failed to parse timestamp string: '{Timestamp}'", timestampStr);
return null;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to extract timestamp from display label");
return null;
}
}
public bool TryParseTimestamp(string timestampStr, string? timezoneAbbreviation, out DateTime timestampUtc)
{
timestampUtc = DateTime.MinValue;
try
{
TimeZoneInfo timeZoneInfo;
if (!string.IsNullOrEmpty(timezoneAbbreviation))
{
if (timezoneAbbreviation.Contains("AEST", StringComparison.OrdinalIgnoreCase))
timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById("Australia/Brisbane");
else if (timezoneAbbreviation.Contains("AEDT", StringComparison.OrdinalIgnoreCase))
timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById("Australia/Sydney");
else
{
var timezone = _configuration.GetValue<string>("Timezone");
if (string.IsNullOrEmpty(timezone))
return false;
timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(timezone);
}
}
else
{
var timezone = _configuration.GetValue<string>("Timezone");
if (string.IsNullOrEmpty(timezone))
return false;
timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(timezone);
}
timestampStr = Regex.Replace(timestampStr.Trim(), @"\s+", " ");
var formats = new[]
{
"dddd d MMM, h:mm tt",
"d MMM, h:mm tt",
"dddd d MMM, hh:mm tt",
"d MMM, hh:mm tt",
"dddd dd MMM, h:mm tt",
"dd MMM, h:mm tt",
"dddd d MMM, h:mmtt",
"d MMM, h:mmtt",
"dddd d MMM, hh:mmtt",
"d MMM, hh:mmtt",
"dddd dd MMM, h:mmtt",
"dd MMM, h:mmtt",
"dddd dd MMM, hh:mmtt",
"dd MMM, hh:mmtt"
};
var culture = new System.Globalization.CultureInfo("en-AU");
DateTime localTime = default;
var parsed = false;
foreach (var format in formats)
{
if (DateTime.TryParseExact(timestampStr, format, culture,
System.Globalization.DateTimeStyles.None, out localTime))
{
parsed = true;
break;
}
}
if (!parsed)
return false;
if (localTime.Year == 1)
{
localTime = new DateTime(DateTime.UtcNow.Year, localTime.Month, localTime.Day,
localTime.Hour, localTime.Minute, localTime.Second);
}
timestampUtc = TimeZoneInfo.ConvertTimeToUtc(localTime, timeZoneInfo);
return true;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse timestamp: {Timestamp}", timestampStr);
return false;
}
}
public async Task WaitForDisplayLabelChangeAsync(
IPage page,
ISelectorService selectorService,
DateTime currentTimestamp,
ScrapingContext? context = null,
int maxWaitMs = 5000,
CancellationToken cancellationToken = default)
{
_ = cancellationToken;
try
{
var startTime = DateTime.UtcNow;
while ((DateTime.UtcNow - startTime).TotalMilliseconds < maxWaitMs)
{
var newTimestamp = await ReadUtcAsync(page, selectorService, context, cancellationToken);
if (newTimestamp.HasValue && newTimestamp.Value != currentTimestamp)
return;
await page.WaitForTimeoutAsync(200);
}
_logger.LogDebug(
"Display label did not change from {CurrentTimestamp} within {MaxWaitMs}ms",
currentTimestamp,
maxWaitMs);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error waiting for display label to change");
}
}
}
@@ -3,13 +3,13 @@ using BomLocalService.Services.Interfaces;
using BomLocalService.Services.Scraping;
using BomLocalService.Utilities;
using Microsoft.Playwright;
using System.Text.RegularExpressions;
namespace BomLocalService.Services.Scraping.Steps.Capture;
public class CaptureFramesStep : BaseScrapingStep
{
private readonly ICacheService _cacheService;
private readonly IScrubDisplayTimestampParser _scrubDisplayTime;
private readonly int _tileRenderWaitMs;
private readonly ScreenshotCropConfig _cropConfig;
@@ -21,19 +21,44 @@ public class CaptureFramesStep : BaseScrapingStep
ISelectorService selectorService,
IDebugService debugService,
IConfiguration configuration,
ICacheService cacheService)
ICacheService cacheService,
IScrubDisplayTimestampParser scrubDisplayTime)
: base(logger, selectorService, debugService, configuration)
{
_cacheService = cacheService;
_tileRenderWaitMs = configuration.GetValue<int>("Screenshot:TileRenderWaitMs", 5000);
_scrubDisplayTime = scrubDisplayTime;
var tileRenderWaitMsConfig = configuration.GetValue<int?>("Screenshot:TileRenderWaitMs");
if (!tileRenderWaitMsConfig.HasValue)
{
throw new InvalidOperationException("Screenshot:TileRenderWaitMs configuration is required. Set it in appsettings.json or via SCREENSHOT__TILERENDERWAITMS environment variable.");
}
_tileRenderWaitMs = tileRenderWaitMsConfig.Value;
var cropSection = configuration.GetSection("Screenshot:Crop");
var cropXConfig = cropSection.GetValue<int?>("X");
if (!cropXConfig.HasValue)
{
throw new InvalidOperationException("Screenshot:Crop:X configuration is required. Set it in appsettings.json or via SCREENSHOT__CROP__X environment variable.");
}
var cropYConfig = cropSection.GetValue<int?>("Y");
if (!cropYConfig.HasValue)
{
throw new InvalidOperationException("Screenshot:Crop:Y configuration is required. Set it in appsettings.json or via SCREENSHOT__CROP__Y environment variable.");
}
var cropRightOffsetConfig = cropSection.GetValue<int?>("RightOffset");
if (!cropRightOffsetConfig.HasValue)
{
throw new InvalidOperationException("Screenshot:Crop:RightOffset configuration is required. Set it in appsettings.json or via SCREENSHOT__CROP__RIGHTOFFSET environment variable.");
}
_cropConfig = new ScreenshotCropConfig
{
X = cropSection.GetValue<int>("X", 0),
Y = cropSection.GetValue<int>("Y", 0),
RightOffset = cropSection.GetValue<int>("RightOffset", 0),
Height = cropSection.GetValue<int?>("Height")
X = cropXConfig.Value,
Y = cropYConfig.Value,
RightOffset = cropRightOffsetConfig.Value,
Height = cropSection.GetValue<int?>("Height") // Height is optional (nullable)
};
}
@@ -55,7 +80,7 @@ public class CaptureFramesStep : BaseScrapingStep
var frames = new List<RadarFrame>();
var stepForwardButton = SelectorService.GetLocator(context.Page, Selectors.StepForwardButton);
int? previousMinutesAgo = null;
DateTime? previousTimestamp = null;
for (int frameIndex = 0; frameIndex < frameCount; frameIndex++)
{
@@ -67,37 +92,49 @@ public class CaptureFramesStep : BaseScrapingStep
// This is especially important for frame 0 which was just selected in ResetToFirstFrame
await context.Page.WaitForTimeoutAsync(300);
// Try extracting with a retry in case the label is still updating
var minutesAgo = await ExtractMinutesAgoFromDisplayAsync(context.Page);
if (minutesAgo == null)
// Try extracting timestamp with a retry in case the label is still updating
var frameTimestamp = await _scrubDisplayTime.ReadUtcAsync(context.Page, SelectorService, context, cancellationToken);
if (frameTimestamp == null)
{
// Retry once after a short wait in case label was updating
await context.Page.WaitForTimeoutAsync(200);
minutesAgo = await ExtractMinutesAgoFromDisplayAsync(context.Page);
}
if (minutesAgo == null && context.FrameInfo != null && frameIndex < context.FrameInfo.Count)
{
var (_, defaultMinutesAgo) = context.FrameInfo[frameIndex];
minutesAgo = defaultMinutesAgo;
Logger.LogWarning("Step {Step}: Failed to extract minutes from display label for frame {FrameIndex}, using default: {MinutesAgo}", Name, frameIndex, minutesAgo);
frameTimestamp = await _scrubDisplayTime.ReadUtcAsync(context.Page, SelectorService, context, cancellationToken);
}
if (frameIndex > 0 && previousMinutesAgo.HasValue && minutesAgo == previousMinutesAgo.Value)
// Fallback: calculate expected timestamp from observation time and frame index if we can't parse it
if (frameTimestamp == null && context.LastUpdatedInfo?.ObservationTime != null && context.FrameInfo != null && frameIndex < context.FrameInfo.Count)
{
Logger.LogWarning("Step {Step}: Frame {FrameIndex} has same minutesAgo ({MinutesAgo}) as previous frame. Waiting for display to update...", Name, frameIndex, minutesAgo);
await WaitForDisplayLabelToChangeAsync(context.Page, previousMinutesAgo.Value);
minutesAgo = await ExtractMinutesAgoFromDisplayAsync(context.Page);
if (minutesAgo == null || minutesAgo == previousMinutesAgo.Value)
var (_, defaultMinutesAgo) = context.FrameInfo[frameIndex];
frameTimestamp = context.LastUpdatedInfo.ObservationTime.AddMinutes(-defaultMinutesAgo);
Logger.LogWarning("Step {Step}: Failed to extract timestamp from display label for frame {FrameIndex}, calculated from observation time: {Timestamp}",
Name, frameIndex, frameTimestamp);
}
if (frameIndex > 0 && previousTimestamp.HasValue && frameTimestamp == previousTimestamp.Value)
{
Logger.LogWarning("Step {Step}: Frame {FrameIndex} has same timestamp ({Timestamp}) as previous frame. Waiting for display to update...",
Name, frameIndex, frameTimestamp);
await _scrubDisplayTime.WaitForDisplayLabelChangeAsync(
context.Page, SelectorService, previousTimestamp.Value, context, cancellationToken: cancellationToken);
frameTimestamp = await _scrubDisplayTime.ReadUtcAsync(context.Page, SelectorService, context, cancellationToken);
if (frameTimestamp == null || frameTimestamp == previousTimestamp.Value)
{
if (context.FrameInfo != null && frameIndex < context.FrameInfo.Count)
if (context.LastUpdatedInfo?.ObservationTime != null && context.FrameInfo != null && frameIndex < context.FrameInfo.Count)
{
var (_, defaultMinutesAgo) = context.FrameInfo[frameIndex];
minutesAgo = defaultMinutesAgo;
Logger.LogWarning("Step {Step}: Display label did not update for frame {FrameIndex}, using calculated default: {MinutesAgo}", Name, frameIndex, minutesAgo);
frameTimestamp = context.LastUpdatedInfo.ObservationTime.AddMinutes(-defaultMinutesAgo);
Logger.LogWarning("Step {Step}: Display label did not update for frame {FrameIndex}, calculated from observation time: {Timestamp}",
Name, frameIndex, frameTimestamp);
}
}
}
if (frameTimestamp == null)
{
Logger.LogError("Step {Step}: Could not determine timestamp for frame {FrameIndex}, skipping", Name, frameIndex);
continue;
}
var radarFolder = FilePathHelper.GetDataTypeFolderPath(context.CacheFolderPath, CachedDataType.Radar);
if (!Directory.Exists(radarFolder))
{
@@ -111,13 +148,13 @@ public class CaptureFramesStep : BaseScrapingStep
{
FrameIndex = frameIndex,
ImagePath = framePath,
MinutesAgo = minutesAgo ?? 0
AbsoluteObservationTime = frameTimestamp.Value
});
previousMinutesAgo = minutesAgo;
previousTimestamp = frameTimestamp;
Logger.LogInformation("Step {Step}: Frame {FrameIndex} saved: {Path} ({MinutesAgo} minutes ago)",
Name, frameIndex, framePath, minutesAgo ?? 0);
Logger.LogInformation("Step {Step}: Frame {FrameIndex} saved: {Path} (timestamp: {Timestamp} UTC)",
Name, frameIndex, framePath, frameTimestamp.Value);
_cacheService.RecordUpdateProgressByFolder(context.CacheFolderPath, CacheUpdatePhase.CapturingFrames, frameIndex + 1, frameCount);
@@ -127,13 +164,14 @@ public class CaptureFramesStep : BaseScrapingStep
{
await DismissModalOverlaysAsync(context.Page);
var currentMinutesAgo = await ExtractMinutesAgoFromDisplayAsync(context.Page);
var currentTimestamp = await _scrubDisplayTime.ReadUtcAsync(context.Page, SelectorService, context, cancellationToken);
await stepForwardButton.ClickAsync(new LocatorClickOptions { Force = true });
if (currentMinutesAgo.HasValue)
if (currentTimestamp.HasValue)
{
await WaitForDisplayLabelToChangeAsync(context.Page, currentMinutesAgo.Value);
await _scrubDisplayTime.WaitForDisplayLabelChangeAsync(
context.Page, SelectorService, currentTimestamp.Value, context, cancellationToken: cancellationToken);
}
else
{
@@ -164,138 +202,6 @@ public class CaptureFramesStep : BaseScrapingStep
}
}
private async Task<int?> ExtractMinutesAgoFromDisplayAsync(IPage page)
{
try
{
var timeLabelLocator = SelectorService.GetLocator(page, Selectors.TimeDisplayLabel);
var timeLabel = await timeLabelLocator.TextContentAsync();
if (string.IsNullOrEmpty(timeLabel))
{
Logger.LogDebug("Time display label is empty");
return null;
}
var trimmedLabel = timeLabel.Trim();
Logger.LogInformation("Extracting minutes from display label: '{Label}'", trimmedLabel);
// Parse timestamp format (current BOM website format): "Wednesday 17 Dec, 11:05 pm" or "17 Dec, 11:05 pm"
Logger.LogInformation("Trying timestamp pattern: '{Pattern}'", TextPatterns.TimestampPattern);
var timestampMatch = Regex.Match(trimmedLabel, TextPatterns.TimestampPattern, RegexOptions.IgnoreCase);
if (timestampMatch.Success)
{
var timestampStr = timestampMatch.Groups[0].Value;
Logger.LogInformation("Matched timestamp pattern: '{Timestamp}' from label: '{Label}'", timestampStr, trimmedLabel);
if (TryParseTimestamp(timestampStr, out var timestamp))
{
var minutesAgo = (int)(DateTime.UtcNow - timestamp).TotalMinutes;
Logger.LogInformation("Parsed timestamp: {Timestamp} UTC, calculated minutes ago: {Minutes}", timestamp, minutesAgo);
if (minutesAgo >= 0 && minutesAgo <= 120) // Reasonable range: 0-2 hours
{
Logger.LogInformation("Successfully calculated minutes ago from timestamp: {Minutes}", minutesAgo);
return minutesAgo;
}
else
{
Logger.LogWarning("Calculated minutes ago ({Minutes}) outside reasonable range (0-120)", minutesAgo);
}
}
else
{
Logger.LogWarning("Failed to parse timestamp string: '{Timestamp}'", timestampStr);
}
}
else
{
Logger.LogWarning("Display label did not match timestamp pattern. Label: '{Label}', Pattern: '{Pattern}'", trimmedLabel, TextPatterns.TimestampPattern);
}
return null;
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Failed to extract minutes from display label");
return null;
}
}
private bool TryParseTimestamp(string timestampStr, out DateTime timestamp)
{
timestamp = DateTime.MinValue;
try
{
// Try common Australian date formats
// Format: "Wednesday 17 Dec, 11:05 pm" or "17 Dec, 11:05 pm"
var formats = new[]
{
"dddd d MMM, h:mm tt", // Wednesday 17 Dec, 11:05 pm
"d MMM, h:mm tt", // 17 Dec, 11:05 pm
"dddd d MMM, hh:mm tt", // Wednesday 17 Dec, 11:05 pm (with leading zero)
"d MMM, hh:mm tt", // 17 Dec, 11:05 pm (with leading zero)
"dddd dd MMM, h:mm tt", // Wednesday 17 Dec, 11:05 pm (with leading zero day)
"dd MMM, h:mm tt" // 17 Dec, 11:05 pm (with leading zero day)
};
var culture = new System.Globalization.CultureInfo("en-AU");
foreach (var format in formats)
{
if (DateTime.TryParseExact(timestampStr, format, culture,
System.Globalization.DateTimeStyles.AssumeLocal, out timestamp))
{
// If year is not specified, assume current year
if (timestamp.Year == 1)
{
timestamp = new DateTime(DateTime.Now.Year, timestamp.Month, timestamp.Day,
timestamp.Hour, timestamp.Minute, timestamp.Second);
}
// If the parsed time is in the future (likely same day next year), adjust
if (timestamp > DateTime.Now && timestamp < DateTime.Now.AddDays(1))
{
// Already correct
}
else if (timestamp > DateTime.Now)
{
// Likely parsed as next year, adjust to this year
timestamp = timestamp.AddYears(-1);
}
return true;
}
}
return false;
}
catch
{
return false;
}
}
private async Task WaitForDisplayLabelToChangeAsync(IPage page, int currentMinutesAgo, int maxWaitMs = 5000)
{
try
{
var startTime = DateTime.UtcNow;
while ((DateTime.UtcNow - startTime).TotalMilliseconds < maxWaitMs)
{
var newMinutesAgo = await ExtractMinutesAgoFromDisplayAsync(page);
if (newMinutesAgo.HasValue && newMinutesAgo.Value != currentMinutesAgo)
{
return;
}
await page.WaitForTimeoutAsync(200);
}
Logger.LogDebug("Display label did not change from {CurrentMinutesAgo} within {MaxWaitMs}ms", currentMinutesAgo, maxWaitMs);
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Error waiting for display label to change");
}
}
private async Task DismissModalOverlaysAsync(IPage page)
{
try
+33 -6
View File
@@ -31,16 +31,43 @@ public class PauseRadarStep : BaseScrapingStep
var playPauseButton = SelectorService.GetLocator(context.Page, Selectors.PlayPauseButton);
await playPauseButton.WaitForAsync(new LocatorWaitForOptions { Timeout = 5000 });
var buttonLabel = await playPauseButton.Locator(Selectors.PlayPauseLabel.Selectors[0]).TextContentAsync();
if (buttonLabel?.Trim().Equals(TextPatterns.ExpectedTexts["PauseButtonLabel"], StringComparison.OrdinalIgnoreCase) == true)
// New spatial map often uses one step-through control; label may be on the button or in .bom-scrub-action__label
string? buttonLabel = null;
try
{
var labelLoc = playPauseButton.Locator(Selectors.PlayPauseLabel.Selectors[0]).First;
if (await labelLoc.CountAsync() > 0)
buttonLabel = (await labelLoc.TextContentAsync())?.Trim();
}
catch
{
/* use inner text */
}
buttonLabel ??= (await playPauseButton.InnerTextAsync())?.Trim();
if (buttonLabel?.Equals(TextPatterns.ExpectedTexts["PauseButtonLabel"], StringComparison.OrdinalIgnoreCase) == true)
{
Logger.LogInformation("Step {Step}: Radar is playing, pausing it", Name);
await playPauseButton.ClickAsync();
await context.Page.WaitForTimeoutAsync(300);
buttonLabel = await playPauseButton.Locator(Selectors.PlayPauseLabel.Selectors[0]).TextContentAsync();
if (buttonLabel?.Trim().Equals(TextPatterns.ExpectedTexts["PlayButtonLabel"], StringComparison.OrdinalIgnoreCase) != true)
// Clear so post-click read always runs; otherwise stale "Pause" skips the ??= InnerText fallback when the label child is missing
buttonLabel = null;
try
{
var labelLoc = playPauseButton.Locator(Selectors.PlayPauseLabel.Selectors[0]).First;
if (await labelLoc.CountAsync() > 0)
buttonLabel = (await labelLoc.TextContentAsync())?.Trim();
}
catch
{
buttonLabel = null;
}
buttonLabel ??= (await playPauseButton.InnerTextAsync())?.Trim();
if (buttonLabel?.Equals(TextPatterns.ExpectedTexts["PlayButtonLabel"], StringComparison.OrdinalIgnoreCase) != true)
{
Logger.LogWarning("Step {Step}: Radar may not be paused after click, continuing anyway", Name);
}
@@ -1,94 +1,134 @@
using BomLocalService.Models;
using BomLocalService.Services.Interfaces;
using BomLocalService.Services.Scraping;
using BomLocalService.Utilities;
using Microsoft.Playwright;
namespace BomLocalService.Services.Scraping.Steps.Map;
/// <summary>
/// Aligns the radar scrubber to the oldest frame (historical frame 0) without clicking timeline segments.
/// The BOM UI now often exposes a single step-through control; segment pills and <c>bom-scrub-tl</c> are unreliable.
/// Strategy: while paused, read <see cref="IScrubDisplayTimestampParser"/> from the display label, step forward
/// through one full loop to find the minimum time, then step until that time shows again.
/// </summary>
public class ResetToFirstFrameStep : BaseScrapingStep
{
private readonly IScrubDisplayTimestampParser _scrubDisplayTime;
public override string Name => "ResetToFirstFrame";
public override string[] Prerequisites => new[] { "PauseRadar" };
public override string[] Prerequisites => new[] { "ExtractMetadata" };
public ResetToFirstFrameStep(
ILogger<ResetToFirstFrameStep> logger,
ISelectorService selectorService,
IDebugService debugService,
IConfiguration configuration)
IConfiguration configuration,
IScrubDisplayTimestampParser scrubDisplayTime)
: base(logger, selectorService, debugService, configuration)
{
_scrubDisplayTime = scrubDisplayTime;
}
public override bool CanExecute(ScrapingContext context)
{
return context.CurrentState >= PageState.SlideshowPaused;
return context.CurrentState >= PageState.SlideshowPaused
&& context.LastUpdatedInfo != null;
}
public override async Task<ScrapingStepResult> ExecuteAsync(ScrapingContext context, CancellationToken cancellationToken)
{
try
{
Logger.LogInformation("Step {Step}: Resetting to first frame (frame 0)", Name);
try
Logger.LogInformation("Step {Step}: Aligning to oldest frame via step-through + display label (no segment clicks)", Name);
var frameCount = CacheHelper.GetFrameCountForDataType(Configuration, CachedDataType.Radar);
var elementWaitMs = Configuration.GetValue<int?>("Scraping:Timeouts:ElementWait") ?? 10000;
var stepBtn = SelectorService.GetLocator(context.Page, Selectors.StepForwardButton);
await stepBtn.WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible, Timeout = elementWaitMs });
// Phase 1 — sample one full loop of display times while paused; oldest = frame 0 target
var samples = new List<DateTime>();
for (var i = 0; i < frameCount; i++)
{
// Try JavaScript click first to avoid thumb interception
var clicked = await context.Page.EvaluateAsync<bool>(@"() => {
const segment = document.querySelector('[data-testid=""bom-scrub-segment""][data-id=""0""]');
if (segment) {
segment.click();
return true;
var t = await _scrubDisplayTime.ReadUtcAsync(context.Page, SelectorService, context, cancellationToken);
if (t.HasValue)
samples.Add(t.Value);
if (i == frameCount - 1)
break;
var beforeStep = t;
await stepBtn.ClickAsync(new LocatorClickOptions { Force = true });
if (beforeStep.HasValue)
{
await _scrubDisplayTime.WaitForDisplayLabelChangeAsync(
context.Page, SelectorService, beforeStep.Value, context, maxWaitMs: 8000, cancellationToken: cancellationToken);
}
else
await context.Page.WaitForTimeoutAsync(800);
}
DateTime? frame0Utc = null;
if (samples.Count > 0)
{
frame0Utc = samples.Min();
Logger.LogInformation("Step {Step}: Oldest time in sampled loop (frame 0 target): {Utc:o} UTC", Name, frame0Utc);
}
else
{
Logger.LogWarning("Step {Step}: Could not read any display timestamps; tile timing may be wrong", Name);
}
// Phase 2 — step until the display matches the oldest sample
if (frame0Utc.HasValue)
{
const double matchMinutes = 2.0;
for (var attempt = 0; attempt < frameCount + 4; attempt++)
{
var cur = await _scrubDisplayTime.ReadUtcAsync(context.Page, SelectorService, context, cancellationToken);
if (cur.HasValue && Math.Abs((cur.Value - frame0Utc.Value).TotalMinutes) <= matchMinutes)
{
Logger.LogInformation("Step {Step}: Display matches frame 0 target (≤{MatchMinutes:F0} min)", Name, matchMinutes);
break;
}
return false;
}");
if (clicked)
{
await context.Page.WaitForTimeoutAsync(500);
Logger.LogInformation("Step {Step}: Successfully clicked frame 0 segment via JavaScript", Name);
}
else
{
// Fallback to locator click with force
var firstFrameSegment = context.Page.Locator("[data-testid='bom-scrub-segment'][data-id='0']").First;
await firstFrameSegment.WaitForAsync(new LocatorWaitForOptions { Timeout = 5000 });
await firstFrameSegment.ClickAsync(new LocatorClickOptions { Force = true });
await context.Page.WaitForTimeoutAsync(500);
Logger.LogInformation("Step {Step}: Successfully clicked frame 0 segment via force click", Name);
await stepBtn.ClickAsync(new LocatorClickOptions { Force = true });
if (cur.HasValue)
{
await _scrubDisplayTime.WaitForDisplayLabelChangeAsync(
context.Page, SelectorService, cur.Value, context, maxWaitMs: 8000, cancellationToken: cancellationToken);
}
else
await context.Page.WaitForTimeoutAsync(800);
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Step {Step}: Failed to click first frame segment, continuing anyway", Name);
}
await SaveDebugAsync(context, 9, "frame_0_selected", cancellationToken);
// Verify scrubber is at position 0
Logger.LogInformation("Step {Step}: Verifying scrubber is at position 0", Name);
await SaveDebugAsync(context, 9, "frame_0_aligned", cancellationToken);
// Best-effort only: segment-based checks are unreliable on the new spatial map layout
try
{
var activeSegment = await context.Page.EvaluateAsync<bool>(JavaScriptTemplates.CheckActiveFrameSegment);
if (activeSegment)
{
Logger.LogInformation("Step {Step}: Scrubber confirmed at position 0", Name);
}
else
{
Logger.LogDebug("Step {Step}: Could not confirm scrubber position, but continuing", Name);
}
var onSegmentZero = await context.Page.EvaluateAsync<bool>(JavaScriptTemplates.CheckActiveFrameSegment);
if (onSegmentZero)
Logger.LogDebug("Step {Step}: Segment check reports frame 0 (informational)", Name);
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Step {Step}: Scrubber position verification failed, continuing anyway", Name);
Logger.LogDebug(ex, "Step {Step}: Segment position check skipped", Name);
}
await SaveDebugAsync(context, 10, "scrubber_at_position_0", cancellationToken);
// Wait for frame 0 tiles to fully render
var tileRenderWaitMs = Configuration.GetValue<int>("Screenshot:TileRenderWaitMs", 5000);
await context.Page.WaitForTimeoutAsync(tileRenderWaitMs);
await SaveDebugAsync(context, 10, "scrubber_frame0", cancellationToken);
var tileRenderWaitMsConfig = Configuration.GetValue<int?>("Screenshot:TileRenderWaitMs");
if (!tileRenderWaitMsConfig.HasValue)
{
throw new InvalidOperationException("Screenshot:TileRenderWaitMs configuration is required. Set it in appsettings.json or via SCREENSHOT__TILERENDERWAITMS environment variable.");
}
await context.Page.WaitForTimeoutAsync(tileRenderWaitMsConfig.Value);
context.CurrentState = PageState.Frame0Selected;
return ScrapingStepResult.Successful();
}
@@ -100,4 +140,3 @@ public class ResetToFirstFrameStep : BaseScrapingStep
}
}
}
@@ -18,7 +18,12 @@ public class WaitForMapReadyStep : BaseScrapingStep
IConfiguration configuration)
: base(logger, selectorService, debugService, configuration)
{
_tileRenderWaitMs = configuration.GetValue<int>("Screenshot:TileRenderWaitMs", 5000);
var tileRenderWaitMsConfig = configuration.GetValue<int?>("Screenshot:TileRenderWaitMs");
if (!tileRenderWaitMsConfig.HasValue)
{
throw new InvalidOperationException("Screenshot:TileRenderWaitMs configuration is required. Set it in appsettings.json or via SCREENSHOT__TILERENDERWAITMS environment variable.");
}
_tileRenderWaitMs = tileRenderWaitMsConfig.Value;
}
public override bool CanExecute(ScrapingContext context)
@@ -1,15 +1,18 @@
using System.Text.Json;
using BomLocalService.Models;
using BomLocalService.Services.Interfaces;
using BomLocalService.Services.Scraping;
using BomLocalService.Utilities;
namespace BomLocalService.Services.Scraping.Steps.Metadata;
public class ExtractMetadataStep : BaseScrapingStep
{
private readonly ITimeParsingService _timeParsingService;
public override string Name => "ExtractMetadata";
public override string[] Prerequisites => new[] { "ResetToFirstFrame" };
public override string[] Prerequisites => new[] { "PauseRadar" };
public ExtractMetadataStep(
ILogger<ExtractMetadataStep> logger,
ISelectorService selectorService,
@@ -20,41 +23,67 @@ public class ExtractMetadataStep : BaseScrapingStep
{
_timeParsingService = timeParsingService;
}
public override bool CanExecute(ScrapingContext context)
{
return context.IsMapReady;
}
public override async Task<ScrapingStepResult> ExecuteAsync(ScrapingContext context, CancellationToken cancellationToken)
{
try
{
Logger.LogInformation("Step {Step}: Extracting metadata and frame information", Name);
var lastUpdatedInfo = await _timeParsingService.ExtractLastUpdatedInfoAsync(context.Page);
context.LastUpdatedInfo = lastUpdatedInfo;
// Extract frame info
var frameCount = CacheHelper.GetFrameCountForDataType(Configuration, CachedDataType.Radar);
var defaults = Enumerable.Range(0, frameCount)
.Select(i => (index: i, minutesAgo: 40 - i * 5))
.ToList();
try
{
var frameInfo = await context.Page.EvaluateAsync<object[]>(JavaScriptTemplates.ExtractFrameInfo);
var result = new List<(int index, int minutesAgo)>();
for (int i = 0; i < 7; i++)
var element = await context.Page.EvaluateAsync<JsonElement>(JavaScriptTemplates.ExtractFrameInfo);
if (element.ValueKind == JsonValueKind.Array && element.GetArrayLength() > 0)
{
var minutesAgo = 40 - (i * 5);
result.Add((i, minutesAgo));
var byIndex = new Dictionary<int, int>();
foreach (var item in element.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.Object)
continue;
var idx = item.GetProperty("index").GetInt32();
if (item.TryGetProperty("minutesAgo", out var m) && m.ValueKind == JsonValueKind.Number)
byIndex[idx] = m.GetInt32();
}
if (byIndex.Count > 0)
{
context.FrameInfo = Enumerable.Range(0, frameCount)
.Select(i => (i, byIndex.TryGetValue(i, out var mm) ? mm : defaults[i].minutesAgo))
.ToList();
Logger.LogInformation("Step {Step}: Parsed frame offsets from segment aria-labels where available", Name);
}
else
{
context.FrameInfo = defaults;
Logger.LogInformation("Step {Step}: Segment times not parsed; using default ladder", Name);
}
}
else
{
context.FrameInfo = defaults;
Logger.LogInformation("Step {Step}: No segment array from page; using default ladder", Name);
}
context.FrameInfo = result;
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Step {Step}: Failed to extract frame info, using defaults", Name);
context.FrameInfo = Enumerable.Range(0, 7)
.Select(i => (i, 40 - (i * 5)))
.ToList();
context.FrameInfo = defaults;
}
return ScrapingStepResult.Successful();
}
catch (Exception ex)
@@ -65,4 +94,3 @@ public class ExtractMetadataStep : BaseScrapingStep
}
}
}
@@ -18,7 +18,8 @@ public class NavigateHomepageStep : BaseScrapingStep
IConfiguration configuration)
: base(logger, selectorService, debugService, configuration)
{
_baseUrl = configuration.GetValue<string>("Scraping:BaseUrl") ?? "https://www.bom.gov.au/";
_baseUrl = configuration.GetValue<string>("Scraping:BaseUrl")
?? throw new InvalidOperationException("Scraping:BaseUrl configuration is required. Set it in appsettings.json or via SCRAPING__BASEURL environment variable.");
}
public override bool CanExecute(ScrapingContext context)
@@ -174,7 +174,12 @@ public class SelectSearchResultStep : BaseScrapingStep
// Wait for forecast page to load
await context.Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded, new PageWaitForLoadStateOptions { Timeout = 15000 });
var dynamicContentWaitMs = Configuration.GetValue<int>("Screenshot:DynamicContentWaitMs", 2000);
var dynamicContentWaitMsConfig = Configuration.GetValue<int?>("Screenshot:DynamicContentWaitMs");
if (!dynamicContentWaitMsConfig.HasValue)
{
throw new InvalidOperationException("Screenshot:DynamicContentWaitMs configuration is required. Set it in appsettings.json or via SCREENSHOT__DYNAMICCONTENTWAITMS environment variable.");
}
var dynamicContentWaitMs = dynamicContentWaitMsConfig.Value;
await context.Page.WaitForTimeoutAsync(dynamicContentWaitMs);
await SaveDebugAsync(context, 5, "forecast_page_loaded", cancellationToken);
+6 -2
View File
@@ -1,3 +1,5 @@
using BomLocalService.Services.Scraping.Workflows;
namespace BomLocalService.Services.Scraping;
public class WorkflowFactory : IWorkflowFactory
@@ -13,8 +15,10 @@ public class WorkflowFactory : IWorkflowFactory
{
return name switch
{
"RadarScraping" => (IWorkflow<TResponse>)_serviceProvider.GetRequiredService<Workflows.RadarScrapingWorkflow>(),
"TemperatureMap" => (IWorkflow<TResponse>)_serviceProvider.GetRequiredService<Workflows.TemperatureMapWorkflow>(),
"RadarScraping" => _serviceProvider.GetRequiredService<IRadarScrapingWorkflow>() as IWorkflow<TResponse>
?? throw new InvalidOperationException($"IRadarScrapingWorkflow is not IWorkflow<{typeof(TResponse).Name}>"),
"TemperatureMap" => _serviceProvider.GetRequiredService<ITemperatureMapWorkflow>() as IWorkflow<TResponse>
?? throw new InvalidOperationException($"ITemperatureMapWorkflow is not IWorkflow<{typeof(TResponse).Name}>"),
_ => throw new ArgumentException($"Unknown workflow: {name}")
};
}
@@ -0,0 +1,12 @@
using BomLocalService.Models;
using BomLocalService.Services.Interfaces.Registration;
namespace BomLocalService.Services.Scraping.Workflows;
/// <summary>
/// Concrete interface for radar scraping workflow (hides generic IWorkflow&lt;RadarResponse&gt;)
/// </summary>
public interface IRadarScrapingWorkflow : IWorkflow<RadarResponse>, IScopedService
{
}
@@ -0,0 +1,12 @@
using BomLocalService.Models;
using BomLocalService.Services.Interfaces.Registration;
namespace BomLocalService.Services.Scraping.Workflows;
/// <summary>
/// Concrete interface for temperature map workflow (hides generic IWorkflow&lt;RadarResponse&gt;)
/// </summary>
public interface ITemperatureMapWorkflow : IWorkflow<RadarResponse>, IScopedService
{
}
@@ -5,7 +5,7 @@ using BomLocalService.Utilities;
namespace BomLocalService.Services.Scraping.Workflows;
public class RadarScrapingWorkflow : IWorkflow<RadarResponse>
public class RadarScrapingWorkflow : IRadarScrapingWorkflow
{
private readonly ILogger<RadarScrapingWorkflow> _logger;
private readonly IScrapingStepRegistry _stepRegistry;
@@ -28,8 +28,8 @@ public class RadarScrapingWorkflow : IWorkflow<RadarResponse>
"ClickRadarLink",
"WaitForMapReady",
"PauseRadar",
"ResetToFirstFrame",
"ExtractMetadata",
"ResetToFirstFrame",
"CalculateMapBounds",
"CaptureFrames"
};
@@ -44,8 +44,20 @@ public class RadarScrapingWorkflow : IWorkflow<RadarResponse>
_stepRegistry = stepRegistry;
_configuration = configuration;
_cacheService = cacheService;
_cacheExpirationMinutes = configuration.GetValue<double>("CacheExpirationMinutes", 12.5);
_cacheManagementCheckIntervalMinutes = configuration.GetValue<int>("CacheManagement:CheckIntervalMinutes", 5);
var cacheExpirationMinutesConfig = configuration.GetValue<double?>("CacheExpirationMinutes");
if (!cacheExpirationMinutesConfig.HasValue)
{
throw new InvalidOperationException("CacheExpirationMinutes configuration is required. Set it in appsettings.json or via CACHEEXPIRATIONMINUTES environment variable.");
}
_cacheExpirationMinutes = cacheExpirationMinutesConfig.Value;
var cacheManagementCheckIntervalMinutesConfig = configuration.GetValue<int?>("CacheManagement:CheckIntervalMinutes");
if (!cacheManagementCheckIntervalMinutesConfig.HasValue)
{
throw new InvalidOperationException("CacheManagement:CheckIntervalMinutes configuration is required. Set it in appsettings.json or via CACHEMANAGEMENT__CHECKINTERVALMINUTES environment variable.");
}
_cacheManagementCheckIntervalMinutes = cacheManagementCheckIntervalMinutesConfig.Value;
}
public async Task<RadarResponse> ExecuteAsync(ScrapingContext context, CancellationToken cancellationToken)
@@ -7,7 +7,7 @@ namespace BomLocalService.Services.Scraping.Workflows;
/// <summary>
/// Future workflow for temperature map scraping (not yet implemented)
/// </summary>
public class TemperatureMapWorkflow : IWorkflow<RadarResponse>
public class TemperatureMapWorkflow : ITemperatureMapWorkflow
{
private readonly ILogger<TemperatureMapWorkflow> _logger;
+11 -3
View File
@@ -52,9 +52,17 @@ public class SelectorService : ISelectorService
{
throw new ArgumentException($"No selectors configured for {config.Name}");
}
// Returns first selector as locator (for cases where we know it exists)
return page.Locator(config.Selectors[0]).First;
// Match any configured selector (BOM occasionally renames test ids; list is tried in order)
var combined = page.Locator(config.Selectors[0]).First;
for (var i = 1; i < config.Selectors.Length; i++)
{
combined = combined.Or(page.Locator(config.Selectors[i]).First);
}
// Or() unions alternatives; BOM sometimes renders duplicates (e.g. responsive + hidden).
// Pin to a single element so Playwright strict mode does not reject clicks.
return combined.First;
}
}
+97 -108
View File
@@ -14,7 +14,13 @@ public class TimeParsingService : ITimeParsingService
public TimeParsingService(ILogger<TimeParsingService> logger, IConfiguration configuration)
{
_logger = logger;
var timezone = configuration.GetValue<string>("Timezone") ?? "Australia/Brisbane";
// Get timezone from configuration (default from appsettings.json, can be overridden via TIMEZONE environment variable)
var timezone = configuration.GetValue<string>("Timezone");
if (string.IsNullOrEmpty(timezone))
{
throw new InvalidOperationException("Timezone configuration is required. Set it in appsettings.json or via TIMEZONE environment variable.");
}
try
{
@@ -23,8 +29,8 @@ public class TimeParsingService : ITimeParsingService
}
catch (TimeZoneNotFoundException)
{
_logger.LogWarning("Timezone {Timezone} not found, defaulting to Australia/Brisbane", timezone);
_timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById("Australia/Brisbane");
_logger.LogError("Timezone {Timezone} not found. Please use a valid IANA timezone identifier.", timezone);
throw new InvalidOperationException($"Invalid timezone '{timezone}'. Please use a valid IANA timezone identifier (e.g., Australia/Sydney, Australia/Brisbane).");
}
}
@@ -35,139 +41,111 @@ public class TimeParsingService : ITimeParsingService
{
try
{
// Look for the weather metadata section using the correct selector
// HTML structure: <section data-testid="weatherMetadata"> with aria-label="Last updated"
var metadataSection = page.Locator("section[data-testid='weatherMetadata'], section[aria-label='Last updated']").First;
// Extract text from each div separately to preserve structure
// Structure: <div>Observations: X minutes ago, 9:40 pm AEST</div><div>at Gympie...</div><div>Forecast: ...</div>
// BOM layouts: legacy map used inner divs; location/spatial pages often use C07_WeatherMetadata with
// a single section and visible text in the section (no div split). Prefer normalized innerText of the
// section so both shapes parse the same.
var metadataSection = page.Locator(
"section[data-testid='weatherMetadata'], " +
"section[aria-label='Last updated'], " +
"section[data-component='C07_WeatherMetadata']").First;
var lastUpdatedText = await page.EvaluateAsync<string>(@"() => {
const section = document.querySelector('section[data-testid=""weatherMetadata""]') ||
document.querySelector('section[aria-label=""Last updated""]');
const section =
document.querySelector('section[data-testid=""weatherMetadata""]') ||
document.querySelector('section[aria-label=""Last updated""]') ||
document.querySelector('section[data-component=""C07_WeatherMetadata""]');
if (!section) return null;
// Get all divs and join with newlines to preserve structure
const divs = section.querySelectorAll('div');
return Array.from(divs).map(div => div.textContent.trim()).filter(text => text).join(' ');
const raw = section.innerText || section.textContent || '';
return raw.replace(/\s+/g, ' ').trim();
}");
if (string.IsNullOrEmpty(lastUpdatedText))
{
// Fallback: get all text content
lastUpdatedText = await metadataSection.TextContentAsync();
var fallback = await metadataSection.InnerTextAsync();
lastUpdatedText = string.IsNullOrEmpty(fallback)
? null
: Regex.Replace(fallback, @"\s+", " ").Trim();
}
return ParseLastUpdatedText(lastUpdatedText ?? string.Empty);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to extract last updated information");
return new LastUpdatedInfo
{
ObservationTime = DateTime.UtcNow,
ForecastTime = DateTime.UtcNow
};
_logger.LogError(ex, "Failed to extract last updated information");
throw;
}
}
/// <summary>
/// Parses last updated text to extract observation time, forecast time, weather station, and distance
/// Parses actual time strings to UTC timestamps - client calculates "minutes ago" dynamically
/// </summary>
public LastUpdatedInfo ParseLastUpdatedText(string text)
{
var info = new LastUpdatedInfo();
_logger.LogInformation("Parsing last updated text: {Text}", text);
// Parse observation time - use "minutes ago" as primary source for accuracy
// "Minutes ago" is reliable and doesn't require date guessing
// Time string is used only as fallback if "minutes ago" is unavailable
var minutesAgoMatch = Regex.Match(text, @"Observations:\s*(\d+)\s*minutes?\s*ago", RegexOptions.IgnoreCase);
if (minutesAgoMatch.Success && int.TryParse(minutesAgoMatch.Groups[1].Value, out var minutesAgo))
// Parse observation time from time string (e.g., "9:40 pm AEST")
// Ignore relative prefix — BOM uses "a minute ago", "6 minutes ago", "an hour ago", "2 hours ago", etc.
var observationMatch = Regex.Match(text,
@"Observations:\s*(?:a\s+minute\s+ago|\d+\s*minutes?\s+ago|an\s+hour\s+ago|\d+\s*hours?\s+ago)?[,\s]+([\d:]+(?:\s*[ap]m)?)\s+([A-Z]{3,4})(?:at|\s+at|,|$)",
RegexOptions.IgnoreCase);
if (observationMatch.Success && observationMatch.Groups.Count >= 3 && observationMatch.Groups[1].Success)
{
// Use "minutes ago" as primary source - it's accurate and doesn't require date guessing
info.ObservationTime = DateTime.UtcNow.AddMinutes(-minutesAgo);
_logger.LogInformation("Calculated observation time from 'minutes ago': {MinutesAgo} minutes ago = {Time} UTC",
minutesAgo, info.ObservationTime);
}
else
{
// Fallback to time string parsing only if "minutes ago" is not available
var observationMatch = Regex.Match(text, @"Observations:\s*(?:\d+\s*minutes?\s*ago)?[,\s]+([\d:]+(?:\s*[ap]m)?)\s+([A-Z]{3,4})(?:at|\s+at|,|$)", RegexOptions.IgnoreCase);
if (observationMatch.Success && observationMatch.Groups.Count >= 3 && observationMatch.Groups[1].Success)
var timeStr = observationMatch.Groups[1].Value.Trim();
var timezoneStr = observationMatch.Groups[2].Success ? observationMatch.Groups[2].Value.Trim() : null;
// Clean up timezone string - remove "at" if it got concatenated (e.g., "AESTat" -> "AEST")
if (!string.IsNullOrEmpty(timezoneStr) && timezoneStr.EndsWith("at", StringComparison.OrdinalIgnoreCase) && timezoneStr.Length > 2)
{
var timeStr = observationMatch.Groups[1].Value.Trim();
var timezoneStr = observationMatch.Groups[2].Success ? observationMatch.Groups[2].Value.Trim() : null;
// Clean up timezone string - remove "at" if it got concatenated (e.g., "AESTat" -> "AEST")
if (!string.IsNullOrEmpty(timezoneStr) && timezoneStr.EndsWith("at", StringComparison.OrdinalIgnoreCase) && timezoneStr.Length > 2)
{
timezoneStr = timezoneStr.Substring(0, timezoneStr.Length - 2);
}
if (TryParseTimeString(timeStr, timezoneStr, out var observationTime))
{
info.ObservationTime = observationTime;
_logger.LogInformation("Used time string fallback for observation time: {Time} UTC (from '{TimeStr}' {TzStr})",
observationTime, timeStr, timezoneStr ?? "default timezone");
}
else
{
_logger.LogWarning("Failed to parse observation time from both 'minutes ago' and time string, using current time");
info.ObservationTime = DateTime.UtcNow;
}
timezoneStr = timezoneStr.Substring(0, timezoneStr.Length - 2);
}
if (TryParseTimeString(timeStr, timezoneStr, out var observationTime))
{
info.ObservationTime = observationTime;
_logger.LogInformation("Parsed observation time: {Time} UTC (from '{TimeStr}' {TzStr})",
observationTime, timeStr, timezoneStr ?? "default timezone");
}
else
{
_logger.LogWarning("No observation time found in text, using current time");
info.ObservationTime = DateTime.UtcNow;
_logger.LogError("Failed to parse observation time from time string '{TimeStr}' with timezone '{TzStr}'", timeStr, timezoneStr ?? "default");
throw new InvalidOperationException($"Failed to parse observation time from text: {text}");
}
}
// Parse forecast time - use "minutes ago" or "an hour ago" as primary source
// Handle both "X minutes ago" and "an hour ago" formats
var forecastMinutesAgoMatch = Regex.Match(text, @"Forecast:\s*(\d+)\s*minutes?\s*ago", RegexOptions.IgnoreCase);
var forecastHourAgoMatch = Regex.Match(text, @"Forecast:\s*an\s+hour\s+ago", RegexOptions.IgnoreCase);
if (forecastMinutesAgoMatch.Success && int.TryParse(forecastMinutesAgoMatch.Groups[1].Value, out var forecastMinutesAgo))
{
// Use "minutes ago" as primary source
info.ForecastTime = DateTime.UtcNow.AddMinutes(-forecastMinutesAgo);
_logger.LogInformation("Calculated forecast time from 'minutes ago': {MinutesAgo} minutes ago = {Time} UTC",
forecastMinutesAgo, info.ForecastTime);
}
else if (forecastHourAgoMatch.Success)
{
// Handle "an hour ago" format
info.ForecastTime = DateTime.UtcNow.AddHours(-1);
_logger.LogInformation("Calculated forecast time from 'an hour ago': {Time} UTC", info.ForecastTime);
}
else
{
// Fallback to time string parsing only if "minutes ago" or "an hour ago" is not available
var forecastMatch = Regex.Match(text, @"Forecast:\s*(?:an\s+hour\s+ago|\d+\s*minutes?\s*ago)?[,\s]*([\d:]+(?:\s*[ap]m)?)\s+([A-Z]+)(?:\s+at|$)", RegexOptions.IgnoreCase);
if (forecastMatch.Success && forecastMatch.Groups[1].Success)
_logger.LogError("No observation time found in text: {Text}", text);
throw new InvalidOperationException($"Could not find observation time in text: {text}");
}
// Parse forecast time from time string (e.g., "7:50 pm AEST")
// Ignore relative prefix (minutes/hours ago) — client derives "ago" from the UTC timestamp
var forecastMatch = Regex.Match(text,
@"Forecast:\s*(?:\d+\s*hours?\s+ago|an\s+hour\s+ago|\d+\s*minutes?\s*ago)?[,\s]*([\d:]+(?:\s*[ap]m)?)\s+([A-Z]+)(?:\s+at|$)",
RegexOptions.IgnoreCase);
if (forecastMatch.Success && forecastMatch.Groups[1].Success)
{
var timeStr = forecastMatch.Groups[1].Value.Trim();
var timezoneStr = forecastMatch.Groups[2].Success ? forecastMatch.Groups[2].Value.Trim() : null;
if (TryParseTimeString(timeStr, timezoneStr, out var forecastTime))
{
var timeStr = forecastMatch.Groups[1].Value.Trim();
var timezoneStr = forecastMatch.Groups[2].Success ? forecastMatch.Groups[2].Value.Trim() : null;
if (TryParseTimeString(timeStr, timezoneStr, out var forecastTime))
{
info.ForecastTime = forecastTime;
_logger.LogInformation("Used time string fallback for forecast time: {Time} UTC (from '{TimeStr}' {TzStr})",
forecastTime, timeStr, timezoneStr ?? "default timezone");
}
else
{
_logger.LogWarning("Failed to parse forecast time from both 'minutes ago' and time string, using current time");
info.ForecastTime = DateTime.UtcNow;
}
info.ForecastTime = forecastTime;
_logger.LogInformation("Parsed forecast time: {Time} UTC (from '{TimeStr}' {TzStr})",
forecastTime, timeStr, timezoneStr ?? "default timezone");
}
else
{
_logger.LogWarning("No forecast time found in text, using current time");
info.ForecastTime = DateTime.UtcNow;
_logger.LogError("Failed to parse forecast time from time string '{TimeStr}' with timezone '{TzStr}'", timeStr, timezoneStr ?? "default");
throw new InvalidOperationException($"Failed to parse forecast time from text: {text}");
}
}
else
{
_logger.LogError("No forecast time found in text: {Text}", text);
throw new InvalidOperationException($"Could not find forecast time in text: {text}");
}
// Extract weather station name
var stationMatch = Regex.Match(text, @"at\s+([^,]+)\s+weather\s+station", RegexOptions.IgnoreCase);
@@ -220,20 +198,32 @@ public class TimeParsingService : ITimeParsingService
}
}
// Determine the timezone - use configured timezone if not specified or if it's AEST/AEDT
// Determine the timezone - map AEST/AEDT based on what the BOM website displays
// Note: The BOM website shows the timezone abbreviation for the location being viewed,
// NOT the browser's configured timezone. The browser timezone only affects JavaScript Date operations.
TimeZoneInfo tz = _timeZoneInfo;
if (!string.IsNullOrEmpty(timezoneStr))
{
// AEST/AEDT are typically the same as Australia/Brisbane or Australia/Sydney
// Both Brisbane and Sydney use AEST/AEDT
if (timezoneStr.Contains("AEST", StringComparison.OrdinalIgnoreCase) ||
timezoneStr.Contains("AEDT", StringComparison.OrdinalIgnoreCase))
// Map timezone abbreviations to actual timezones:
// - "AEST" = UTC+10 (Australian Eastern Standard Time)
// Used by Brisbane year-round, and by Sydney/Melbourne during winter
// Since both are UTC+10 when showing AEST, we use Brisbane timezone (UTC+10 year-round)
// - "AEDT" = UTC+11 (Australian Eastern Daylight Time)
// Used by Sydney/Melbourne during summer (Oct-Apr), never Brisbane
// We use Sydney timezone which handles the UTC+11 offset correctly
if (timezoneStr.Contains("AEST", StringComparison.OrdinalIgnoreCase))
{
tz = _timeZoneInfo; // Use configured timezone
// AEST maps to Brisbane timezone (UTC+10) - works for both Brisbane and Sydney in winter
tz = TimeZoneInfo.FindSystemTimeZoneById("Australia/Brisbane");
}
else if (timezoneStr.Contains("AEDT", StringComparison.OrdinalIgnoreCase))
{
// AEDT maps to Sydney timezone (UTC+11) - only appears in summer for Sydney/Melbourne
tz = TimeZoneInfo.FindSystemTimeZoneById("Australia/Sydney");
}
else
{
// Try to find the timezone, fallback to configured
// Try to find the timezone by name, fallback to configured browser timezone
try
{
tz = TimeZoneInfo.FindSystemTimeZoneById(timezoneStr);
@@ -250,8 +240,7 @@ public class TimeParsingService : ITimeParsingService
var dateTimeInTz = nowInTz.Date.Add(localTime.TimeOfDay);
// Only adjust to yesterday if the time is clearly in the future
// Don't use the 12-hour threshold - it's too aggressive for recent observations
// This method is now only used as fallback when "minutes ago" is unavailable
// This ensures we get the correct date for the observation/forecast time
if (dateTimeInTz > nowInTz)
{
// Time is in the future, so it must be from yesterday
+20 -4
View File
@@ -14,8 +14,12 @@ public static class CacheHelper
public static int GetFrameCountForDataType(IConfiguration configuration, CachedDataType dataType)
{
var dataTypeName = dataType.ToString();
var frameCount = configuration.GetValue<int>($"CachedDataTypes:{dataTypeName}:FrameCount", 7);
return frameCount;
var frameCountConfig = configuration.GetValue<int?>($"CachedDataTypes:{dataTypeName}:FrameCount");
if (!frameCountConfig.HasValue)
{
throw new InvalidOperationException($"CachedDataTypes:{dataTypeName}:FrameCount configuration is required. Set it in appsettings.json or via CACHEDDATATYPES__{dataTypeName.ToUpperInvariant()}__FRAMECOUNT environment variable.");
}
return frameCountConfig.Value;
}
/// <summary>
@@ -71,8 +75,20 @@ public static class CacheHelper
{
// Calculate based on actual wait times and frame count
var frameCount = GetFrameCountForDataType(configuration, dataType);
var tileRenderWaitMs = configuration.GetValue<int>("Screenshot:TileRenderWaitMs", 5000);
var dynamicContentWaitMs = configuration.GetValue<int>("Screenshot:DynamicContentWaitMs", 2000);
var tileRenderWaitMsConfig = configuration.GetValue<int?>("Screenshot:TileRenderWaitMs");
if (!tileRenderWaitMsConfig.HasValue)
{
throw new InvalidOperationException("Screenshot:TileRenderWaitMs configuration is required. Set it in appsettings.json or via SCREENSHOT__TILERENDERWAITMS environment variable.");
}
var tileRenderWaitMs = tileRenderWaitMsConfig.Value;
var dynamicContentWaitMsConfig = configuration.GetValue<int?>("Screenshot:DynamicContentWaitMs");
if (!dynamicContentWaitMsConfig.HasValue)
{
throw new InvalidOperationException("Screenshot:DynamicContentWaitMs configuration is required. Set it in appsettings.json or via SCREENSHOT__DYNAMICCONTENTWAITMS environment variable.");
}
var dynamicContentWaitMs = dynamicContentWaitMsConfig.Value;
// Rough calculation:
// - Initial page load and navigation: ~10-15 seconds
+3 -3
View File
@@ -5,12 +5,12 @@ namespace BomLocalService.Utilities;
public static class FilePathHelper
{
/// <summary>
/// Gets the cache directory path from configuration or returns default
/// Gets the cache directory path from configuration (default from appsettings.json, can be overridden via CACHEDIRECTORY environment variable)
/// </summary>
public static string GetCacheDirectory(IConfiguration configuration)
{
return configuration.GetValue<string>("CacheDirectory")
?? Path.Combine(AppContext.BaseDirectory, "cache");
return configuration.GetValue<string>("CacheDirectory")
?? throw new InvalidOperationException("CacheDirectory configuration is required. Set it in appsettings.json or via CACHEDIRECTORY environment variable.");
}
/// <summary>
+1 -12
View File
@@ -63,19 +63,8 @@ public static class ResponseBuilder
}
}
// Calculate AbsoluteObservationTime for all frames if metadata is available
// AbsoluteObservationTime is already set during capture, no calculation needed
// This ensures consistency with the time series endpoint
if (metadata != null && metadata.ObservationTime > DateTime.MinValue.AddYears(1))
{
foreach (var frame in frames)
{
// Only calculate if not already set and MinutesAgo is valid
if (!frame.AbsoluteObservationTime.HasValue && frame.MinutesAgo >= 0)
{
frame.AbsoluteObservationTime = metadata.ObservationTime.AddMinutes(-frame.MinutesAgo);
}
}
}
// Calculate NextUpdateTime based on cache status:
// - If cache is valid: NextUpdateTime = max(CacheExpiresAt, next background service check)
File diff suppressed because it is too large Load Diff
+15 -11
View File
@@ -18,7 +18,6 @@
"CacheManagement": {
"CheckIntervalMinutes": 5,
"InitialDelaySeconds": 10,
"UpdateStaggerSeconds": 2,
"LocationStaggerSeconds": 1
},
"CacheCleanup": {
@@ -62,11 +61,9 @@
"Name": "Search Button",
"Selectors": [
"button[data-testid='searchLabel']",
"button[aria-label='Search for a location']",
"button.search-location__trigger-button",
"button.bom-button:has(span.bom-button__label:has-text('Search for a location'))",
"button:has-text('Search for a location')",
"[data-testid='showSearchModal'] button"
"button[aria-label='Search for a location'].search-location__trigger-button",
"button.search-location__trigger-button.bom-button:has(span.bom-button__label:has-text('Search for a location'))"
],
"TimeoutMs": 10000,
"Required": true,
@@ -138,7 +135,10 @@
},
"PlayPauseButton": {
"Name": "Play Pause Button",
"Selectors": ["button[data-testid='bom-time-scrub-play-pause']"],
"Selectors": [
"button[data-testid='bom-time-scrub-step-through']",
"button[data-testid='bom-time-scrub-play-pause']"
],
"TimeoutMs": 5000,
"Required": true
},
@@ -156,7 +156,11 @@
},
"StepForwardButton": {
"Name": "Step Forward Button",
"Selectors": ["button[data-testid='bom-scrub-utils__right__step-forward']"],
"Selectors": [
"button[data-testid='bom-time-scrub-step-through']",
"button.bom-scrub-action--step-through",
"button[data-testid='bom-scrub-utils__right__step-forward']"
],
"TimeoutMs": 5000,
"Required": true
},
@@ -185,9 +189,9 @@
"ExtractSearchResultsFallback": "() => { const results = Array.from(document.querySelectorAll('li.bom-linklist__item[role=\"listitem\"]')); return results.map(r => r.textContent || ''); }",
"WaitForMapCanvas": "() => { const canvas = document.querySelector('.esri-view-surface canvas'); return canvas && canvas.width > 0 && canvas.height > 0 && canvas.offsetWidth > 0 && canvas.offsetHeight > 0; }",
"WaitForEsriView": "() => { try { const elements = document.querySelectorAll('.esri-view'); for (let el of elements) { if (el.__view && el.__view.ready) { return true; } } } catch(e) {} return false; }",
"CheckActiveFrameSegment": "() => { const segments = Array.from(document.querySelectorAll('[data-testid=\"bom-scrub-segment\"]')); const activeSegment = segments.find(s => { const style = window.getComputedStyle(s); return style.backgroundColor !== 'rgb(148, 148, 148)' && style.backgroundColor !== 'rgb(148, 148, 148)'; }); return activeSegment && activeSegment.getAttribute('data-id') === '0'; }",
"CheckActiveFrameSegment": "() => { const segments = Array.from(document.querySelectorAll('[data-testid=\"bom-scrub-segment\"]')); if (segments.length === 0) return true; const activeSegment = segments.find(s => { const style = window.getComputedStyle(s); return style.backgroundColor !== 'rgb(148, 148, 148)' && style.backgroundColor !== 'rgb(148, 148, 148)'; }); return activeSegment && activeSegment.getAttribute('data-id') === '0'; }",
"WaitForMapContainer": "() => { const container = document.querySelector('.esri-view-surface'); return container && container.offsetWidth > 0 && container.offsetHeight > 0; }",
"ExtractFrameInfo": "() => { const segments = Array.from(document.querySelectorAll('[data-testid=\"bom-scrub-segment\"]')); const now = new Date(); return segments.map((seg, index) => { const ariaLabel = seg.getAttribute('aria-label') || ''; let minutes = null; const timestampMatch = ariaLabel.match(/(?:[A-Za-z]+\\s+)?(\\d{1,2})\\s+([A-Za-z]{3}),?\\s+(\\d{1,2}):(\\d{2})\\s+(am|pm)/i); if (timestampMatch) { try { const day = parseInt(timestampMatch[1]); const monthStr = timestampMatch[2]; const hour12 = parseInt(timestampMatch[3]); const minute = parseInt(timestampMatch[4]); const ampm = timestampMatch[5].toLowerCase(); const monthNames = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; const month = monthNames.indexOf(monthStr.toLowerCase()); if (month >= 0) { let hour24 = hour12; if (ampm === 'pm' && hour12 !== 12) hour24 += 12; if (ampm === 'am' && hour12 === 12) hour24 = 0; const year = now.getFullYear(); const frameTime = new Date(year, month, day, hour24, minute); if (frameTime > now) frameTime.setFullYear(year - 1); const diffMs = now - frameTime; minutes = Math.round(diffMs / (1000 * 60)); if (minutes < 0 || minutes > 120) minutes = null; } } catch(e) {} } return { index: index, minutesAgo: minutes }; }); }",
"ExtractFrameInfo": "() => { const segments = Array.from(document.querySelectorAll('[data-testid=\"bom-scrub-segment\"]')); const now = new Date(); return segments.map((seg, index) => { const ariaLabel = seg.getAttribute('aria-label') || ''; let minutes = null; const timestampMatch = ariaLabel.match(/(?:[A-Za-z]+\\s+)?(\\d{1,2})\\s+([A-Za-z]+),?\\s+(\\d{1,2}):(\\d{2})\\s*(am|pm)/i); if (timestampMatch) { try { const day = parseInt(timestampMatch[1]); const monthStr = timestampMatch[2]; const hour12 = parseInt(timestampMatch[3]); const minute = parseInt(timestampMatch[4]); const ampm = timestampMatch[5].toLowerCase(); const monthNames = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; const month = monthNames.indexOf(monthStr.toLowerCase()); if (month >= 0) { let hour24 = hour12; if (ampm === 'pm' && hour12 !== 12) hour24 += 12; if (ampm === 'am' && hour12 === 12) hour24 = 0; const year = now.getFullYear(); const frameTime = new Date(year, month, day, hour24, minute); if (frameTime > now) frameTime.setFullYear(year - 1); const diffMs = now - frameTime; minutes = Math.round(diffMs / (1000 * 60)); if (minutes < 0 || minutes > 120) minutes = null; } } catch(e) {} } return { index: index, minutesAgo: minutes }; }); }",
"CheckModalOverlay": "() => { const bomOverlay = document.querySelector('.bom-modal-overlay--after-open'); if (bomOverlay && bomOverlay.style.display !== 'none') { return true; } const recaptchaSelectors = ['.g-recaptcha', '#g-recaptcha', '.rc-anchor-container']; for (const selector of recaptchaSelectors) { const el = document.querySelector(selector); if (el) { const style = window.getComputedStyle(el); const rect = el.getBoundingClientRect(); if (style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 200 && rect.height > 200) { return true; } } } const allForms = document.querySelectorAll('form'); for (const form of allForms) { const action = form.getAttribute('action') || ''; const id = form.getAttribute('id') || ''; if (action.includes('feedback') || id.includes('feedback')) { const style = window.getComputedStyle(form); const rect = form.getBoundingClientRect(); if (style.display !== 'none' && rect.width > 200 && rect.height > 200) { const text = form.textContent || ''; if (text.includes('reCAPTCHA') || text.includes('recaptcha') || text.includes('Tell us why')) { return true; } } } } return false; }",
"CheckModalStillVisible": "() => { const bomOverlay = document.querySelector('.bom-modal-overlay--after-open'); if (bomOverlay && bomOverlay.style.display !== 'none') return true; const allForms = document.querySelectorAll('form'); for (const form of allForms) { const action = form.getAttribute('action') || ''; const id = form.getAttribute('id') || ''; if (action.includes('feedback') || id.includes('feedback')) { const style = window.getComputedStyle(form); const rect = form.getBoundingClientRect(); if (style.display !== 'none' && rect.width > 200 && rect.height > 200) { return true; } } } return false; }",
"GetViewportSize": "() => JSON.stringify({ width: window.innerWidth, height: window.innerHeight })",
@@ -195,7 +199,7 @@
},
"TextPatterns": {
"ResultsCountPattern": "(\\d+)\\s+of\\s+(\\d+)",
"TimestampPattern": "(?:[A-Za-z]+\\s+)?\\d{1,2}\\s+[A-Za-z]{3},?\\s+\\d{1,2}:\\d{2}\\s+(?:am|pm)",
"TimestampPattern": "(?:[A-Za-z]+\\s+)?\\d{1,2}\\s+[A-Za-z]+,?\\s+\\d{1,2}:\\d{2}\\s*(?:am|pm)",
"ObservationTimePattern": "Observations:\\s*(\\d+)\\s*minutes?\\s*ago",
"ForecastTimePattern": "Forecast:\\s*(\\d+)\\s*minutes?\\s+ago",
"ForecastHourAgoPattern": "Forecast:\\s*an\\s+hour\\s+ago",
@@ -219,8 +223,8 @@
"ClickRadarLink": { "Enabled": true },
"WaitForMapReady": { "Enabled": true },
"PauseRadar": { "Enabled": true },
"ResetToFirstFrame": { "Enabled": true },
"ExtractMetadata": { "Enabled": true },
"ResetToFirstFrame": { "Enabled": true },
"CalculateMapBounds": { "Enabled": true },
"CaptureFrames": {
"Enabled": true,
-1
View File
@@ -32,7 +32,6 @@ services:
- CACHEEXPIRATIONMINUTES=${CACHEEXPIRATIONMINUTES:-12.5}
- CACHEMANAGEMENT__CHECKINTERVALMINUTES=${CACHEMANAGEMENT__CHECKINTERVALMINUTES:-5}
- CACHEMANAGEMENT__INITIALDELAYSECONDS=${CACHEMANAGEMENT__INITIALDELAYSECONDS:-10}
- CACHEMANAGEMENT__UPDATESTAGGERSECONDS=${CACHEMANAGEMENT__UPDATESTAGGERSECONDS:-2}
- CACHEMANAGEMENT__LOCATIONSTAGGERSECONDS=${CACHEMANAGEMENT__LOCATIONSTAGGERSECONDS:-1}
- CACHECLEANUP__INTERVALHOURS=${CACHECLEANUP__INTERVALHOURS:-1}
- SCREENSHOT__DYNAMICCONTENTWAITMS=${SCREENSHOT__DYNAMICCONTENTWAITMS:-2000}
+72 -9
View File
@@ -1,29 +1,92 @@
#!/bin/bash
set -e
# Function to cleanup stale Xvfb processes and lock files
cleanup_xvfb() {
# Kill any stale Xvfb processes for display :99
pkill -f "Xvfb :99" 2>/dev/null || true
sleep 0.5
# Remove stale lock files
rm -f /tmp/.X99-lock /tmp/.X11-unix/X99 2>/dev/null || true
}
# Function to check if Xvfb is actually functional
check_xvfb_working() {
# Check if process exists and is running (not zombie)
local pid=$(pgrep -f "Xvfb :99" | head -n1)
if [ -z "$pid" ]; then
return 1
fi
# Check if process is actually running (not a zombie)
if ! kill -0 "$pid" 2>/dev/null; then
return 1
fi
return 0
}
# Function to start Xvfb
start_xvfb() {
if ! pgrep -f "Xvfb :99" > /dev/null; then
# Clean up any stale processes/locks first
cleanup_xvfb
if ! check_xvfb_working; then
echo "Starting Xvfb on display :99..."
Xvfb :99 -screen 0 1920x1080x24 -ac +extension GLX +render -noreset > /dev/null 2>&1 &
sleep 1
if ! pgrep -f "Xvfb :99" > /dev/null; then
sleep 1.5
if check_xvfb_working; then
echo "Xvfb started successfully"
return 0
else
echo "ERROR: Xvfb failed to start" >&2
return 1
fi
echo "Xvfb started successfully"
fi
return 0
}
# Function to monitor and restart Xvfb if it dies (runs as background process)
monitor_xvfb() {
local consecutive_failures=0
local max_backoff=60 # Maximum backoff in seconds
local backoff_seconds=5
# Monitor while PID 1 (dotnet after exec) is still running
while kill -0 1 2>/dev/null; do
sleep 5
if ! pgrep -f "Xvfb :99" > /dev/null; then
echo "WARNING: Xvfb process not found, restarting..." >&2
start_xvfb || true
sleep $backoff_seconds
if ! check_xvfb_working; then
consecutive_failures=$((consecutive_failures + 1))
if [ $consecutive_failures -eq 1 ]; then
echo "WARNING: Xvfb not working, attempting restart..." >&2
elif [ $consecutive_failures -le 3 ]; then
echo "WARNING: Xvfb restart attempt $consecutive_failures..." >&2
elif [ $((consecutive_failures % 10)) -eq 0 ]; then
echo "ERROR: Xvfb has failed to start $consecutive_failures times. Display may be unavailable." >&2
fi
if start_xvfb; then
consecutive_failures=0
backoff_seconds=5
else
# Exponential backoff, capped at max_backoff
backoff_seconds=$((backoff_seconds * 2))
if [ $backoff_seconds -gt $max_backoff ]; then
backoff_seconds=$max_backoff
fi
fi
else
# Xvfb is working, reset failure counter
if [ $consecutive_failures -gt 0 ]; then
echo "Xvfb is now working" >&2
consecutive_failures=0
backoff_seconds=5
fi
fi
done
}
@@ -31,7 +94,7 @@ monitor_xvfb() {
# Cleanup function
cleanup() {
echo "Shutting down Xvfb..."
pkill -f "Xvfb :99" || true
cleanup_xvfb
}
# Set up signal handlers for cleanup
+288
View File
@@ -0,0 +1,288 @@
.info-section {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
background: #f8f9fa;
border-radius: 12px;
padding: 15px;
}
@media (min-width: 480px) {
.info-section {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 768px) {
.info-section {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
padding: 20px;
}
}
.radar-section {
background: #f8f9fa;
border-radius: 12px;
padding: 15px;
}
@media (min-width: 768px) {
.radar-section {
padding: 20px;
}
}
.radar-image-container {
position: relative;
width: 100%;
background: #000;
border-radius: 8px;
overflow: hidden;
margin-bottom: 15px;
aspect-ratio: 16/9;
min-height: 250px; /* Smaller on mobile */
}
@media (min-width: 768px) {
.radar-image-container {
margin-bottom: 20px;
min-height: 400px;
}
}
.radar-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: contain;
display: block;
image-rendering: crisp-edges; /* Prevent blurry scaling */
}
.loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 1.2em;
}
.frame-controls {
display: flex;
gap: 8px;
align-items: center;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 15px;
}
@media (min-width: 768px) {
.frame-controls {
gap: 10px;
margin-bottom: 20px;
}
}
.frame-btn {
padding: 10px 20px;
border: 2px solid #667eea;
background: white;
color: #667eea;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
font-size: 0.9em;
}
.frame-btn:hover {
background: #667eea;
color: white;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.frame-btn.active {
background: #667eea;
color: white;
}
.frame-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.play-controls {
display: flex;
gap: 8px;
justify-content: center;
margin-bottom: 15px;
flex-wrap: wrap;
}
@media (min-width: 768px) {
.play-controls {
gap: 10px;
margin-bottom: 20px;
}
}
.play-btn {
padding: 12px 20px;
border: none;
background: #667eea;
color: white;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
font-size: 0.9em;
transition: all 0.2s;
min-height: 44px; /* Touch target size */
flex: 1;
min-width: 100px;
white-space: nowrap;
}
@media (min-width: 480px) {
.play-btn {
flex: 0 1 auto;
min-width: auto;
}
}
@media (min-width: 768px) {
.play-btn {
padding: 12px 30px;
font-size: 1em;
}
}
.play-btn:hover {
background: #5568d3;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.play-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.info-card {
background: white;
border-radius: 8px;
padding: 12px;
border-left: 4px solid #667eea;
}
@media (min-width: 768px) {
.info-card {
padding: 15px;
}
}
.info-card h3 {
font-size: 0.8em;
text-transform: uppercase;
color: #666;
margin-bottom: 6px;
letter-spacing: 0.5px;
}
@media (min-width: 768px) {
.info-card h3 {
font-size: 0.9em;
margin-bottom: 8px;
}
}
.info-card .value {
font-size: 1em;
font-weight: 600;
color: #333;
word-wrap: break-word;
}
@media (min-width: 768px) {
.info-card .value {
font-size: 1.2em;
}
}
.status-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.85em;
font-weight: 600;
margin-top: 5px;
}
.status-valid {
background: #d4edda;
color: #155724;
}
.status-invalid {
background: #f8d7da;
color: #721c24;
}
.status-updating {
background: #fff3cd;
color: #856404;
}
.error {
background: #f8d7da;
color: #721c24;
padding: 15px;
border-radius: 8px;
margin: 20px;
border-left: 4px solid #dc3545;
animation: fadeIn 0.3s;
}
.error.retrying {
background: #fff3cd;
color: #856404;
border-left-color: #ffc107;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.timestamp {
font-size: 0.85em;
color: #666;
margin-top: 5px;
}
.refresh-indicator {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
background: #28a745;
margin-right: 8px;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.frame-info {
text-align: center;
color: #666;
font-size: 0.9em;
margin-top: 10px;
}
+275
View File
@@ -0,0 +1,275 @@
/* Frame Slider Controls */
.frame-slider-container {
width: 100%;
max-width: 100%;
padding: 15px;
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
border-radius: 12px;
border: 2px solid #e0e0e0;
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
box-sizing: border-box;
margin-bottom: 15px;
}
@media (min-width: 768px) {
.frame-slider-container {
padding: 24px;
border-radius: 16px;
margin-bottom: 20px;
}
}
.frame-slider-wrapper {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 12px;
width: 100%;
flex-wrap: wrap;
}
@media (min-width: 480px) {
.frame-slider-wrapper {
gap: 6px;
flex-wrap: nowrap;
}
}
@media (min-width: 768px) {
.frame-slider-wrapper {
gap: 12px;
margin-bottom: 20px;
}
}
.frame-nav-btn {
padding: 10px 16px;
border: 2px solid #667eea;
background: white;
color: #667eea;
border-radius: 10px;
cursor: pointer;
font-weight: 700;
font-size: 0.9em;
transition: all 0.2s;
min-width: 50px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.frame-nav-btn:hover:not(:disabled) {
background: #667eea;
color: white;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.frame-nav-btn:active:not(:disabled) {
transform: translateY(0);
}
.frame-nav-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
background: #f0f0f0;
border-color: #d0d0d0;
color: #999;
}
.frame-slider {
flex: 1;
min-width: 120px;
height: 10px;
border-radius: 5px;
background: #e0e0e0;
outline: none;
-webkit-appearance: none;
max-width: 100%;
cursor: pointer;
transition: background 0.2s;
width: 100%;
order: 3; /* Put slider in middle on mobile when wrapped */
}
@media (min-width: 480px) {
.frame-slider {
min-width: 100px;
order: 0; /* Reset order for larger screens */
}
}
@media (min-width: 768px) {
.frame-slider {
min-width: 0;
}
}
.frame-slider:hover {
background: #d0d0d0;
}
/* Webkit (Chrome, Safari, Edge) */
.frame-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 24px;
height: 24px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
cursor: pointer;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4);
transition: all 0.2s;
border: 3px solid white;
}
.frame-slider::-webkit-slider-thumb:hover {
transform: scale(1.15);
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.6);
}
.frame-slider::-webkit-slider-thumb:active {
transform: scale(1.05);
}
/* Track fill for webkit */
.frame-slider::-webkit-slider-runnable-track {
height: 10px;
border-radius: 5px;
background: linear-gradient(to right, #667eea 0%, #764ba2 100%);
}
/* Firefox */
.frame-slider::-moz-range-thumb {
width: 24px;
height: 24px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
cursor: pointer;
border: 3px solid white;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4);
transition: all 0.2s;
}
.frame-slider::-moz-range-thumb:hover {
transform: scale(1.15);
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.6);
}
.frame-slider::-moz-range-thumb:active {
transform: scale(1.05);
}
.frame-slider::-moz-range-track {
height: 10px;
border-radius: 5px;
background: #e0e0e0;
}
.frame-slider::-moz-range-progress {
height: 10px;
border-radius: 5px;
background: linear-gradient(to right, #667eea 0%, #764ba2 100%);
}
.frame-info-display {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
padding-top: 12px;
border-top: 1px solid #e0e0e0;
}
@media (min-width: 480px) {
.frame-info-display {
grid-template-columns: repeat(2, 1fr);
gap: 16px;
padding-top: 16px;
}
}
@media (min-width: 768px) {
.frame-info-display {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
}
.frame-info-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.85em;
}
@media (min-width: 768px) {
.frame-info-item {
gap: 8px;
font-size: 0.95em;
}
}
.frame-info-label {
color: #667eea;
font-weight: 600;
font-size: 0.8em;
text-transform: uppercase;
letter-spacing: 0.5px;
min-width: 60px;
}
@media (min-width: 768px) {
.frame-info-label {
font-size: 0.9em;
min-width: 70px;
}
}
.frame-info-value {
color: #333;
font-weight: 700;
font-size: 0.95em;
}
@media (min-width: 768px) {
.frame-info-value {
font-size: 1.1em;
}
}
.frame-jump-input {
width: 70px;
padding: 10px 12px;
border: 2px solid #667eea;
border-radius: 8px;
font-size: 0.9em;
text-align: center;
font-weight: 700;
color: #333;
background: white;
transition: all 0.2s;
min-height: 44px; /* Touch target size */
box-sizing: border-box;
}
@media (min-width: 768px) {
.frame-jump-input {
padding: 8px 12px;
font-size: 1em;
min-height: auto;
}
}
.frame-jump-input:focus {
outline: none;
border-color: #764ba2;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.15);
background: #f8f9ff;
}
.frame-jump-input::-webkit-inner-spin-button,
.frame-jump-input::-webkit-outer-spin-button {
opacity: 1;
height: 20px;
}
+122
View File
@@ -0,0 +1,122 @@
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 10px;
color: #333;
}
@media (min-width: 768px) {
body {
padding: 20px;
}
}
.container {
max-width: 1400px;
margin: 0 auto;
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 15px;
}
@media (min-width: 768px) {
.header {
padding: 30px;
gap: 20px;
}
}
.header-content {
flex: 1;
text-align: center;
min-width: 0;
}
.header h1 {
font-size: 1.5em;
margin-bottom: 8px;
font-weight: 700;
word-wrap: break-word;
}
@media (min-width: 768px) {
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
}
}
.header .subtitle {
font-size: 0.9em;
opacity: 0.9;
}
@media (min-width: 768px) {
.header .subtitle {
font-size: 1.1em;
}
}
.header-actions {
display: flex;
gap: 10px;
align-items: center;
}
.settings-header-btn {
background: rgba(255, 255, 255, 0.2);
border: 2px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 10px 16px;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
font-size: 0.9em;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 6px;
min-height: 44px; /* Touch target size */
}
@media (min-width: 768px) {
.settings-header-btn {
padding: 12px 24px;
font-size: 1em;
gap: 8px;
}
}
.settings-header-btn:hover {
background: rgba(255, 255, 255, 0.3);
border-color: rgba(255, 255, 255, 0.5);
transform: translateY(-2px);
}
.content {
display: flex;
flex-direction: column;
gap: 15px;
padding: 15px;
}
@media (min-width: 768px) {
.content {
gap: 20px;
padding: 30px;
}
}
+6
View File
@@ -0,0 +1,6 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
+5
View File
@@ -0,0 +1,5 @@
@import url('./reset.css');
@import url('./layout.css');
@import url('./components.css');
@import url('./frame-slider.css');
+382
View File
@@ -0,0 +1,382 @@
// API client for fetching radar data
import { API_BASE } from './config.js';
import { formatDate, isNetworkError, isNetworkErrorResponse, createCacheStatusData, triggerBackgroundRefresh } from './utils.js';
import { state } from './state.js';
/**
* Fetch cache range information
*/
export async function fetchCacheRange() {
try {
const response = await fetch(`${API_BASE.replace('/api/radar', '/api/cache')}/range`, {
signal: AbortSignal.timeout(10000)
});
if (!response.ok) {
if (isNetworkErrorResponse(response)) {
throw new Error('Network error');
}
console.warn('Failed to fetch cache range');
return { data: null, error: null };
}
const data = await response.json();
state.cacheRangeInfo = data;
// Update UI
const rangeTextEl = document.getElementById('cache-range-text');
if (rangeTextEl) {
if (data.totalCacheFolders > 0) {
const spanHours = data.timeSpanMinutes ? Math.round(data.timeSpanMinutes / 60 * 10) / 10 : 0;
rangeTextEl.innerHTML = `
<div>Oldest: ${formatDate(data.oldestCache.cacheTimestamp)}</div>
<div>Newest: ${formatDate(data.newestCache.cacheTimestamp)}</div>
<div>Total: ${data.totalCacheFolders} cache folders (${spanHours} hours)</div>
`;
} else {
rangeTextEl.textContent = 'No cache data available';
}
}
return { data: data, error: null };
} catch (error) {
console.error('Error fetching cache range:', error);
const rangeTextEl = document.getElementById('cache-range-text');
if (rangeTextEl) {
rangeTextEl.textContent = 'Unable to load cache range';
}
if (isNetworkError(error)) {
return { data: null, error: 'network' };
}
return { data: null, error: null };
}
}
/**
* Fetch historical radar data for extended timespan
*/
export async function fetchHistoricalRadar(startTime, endTime) {
try {
let url = `${API_BASE}/timeseries`;
const params = new URLSearchParams();
if (startTime) params.append('startTime', startTime.toISOString());
if (endTime) params.append('endTime', endTime.toISOString());
if (params.toString()) url += '?' + params.toString();
const response = await fetch(url, {
signal: AbortSignal.timeout(30000) // Longer timeout for potentially large responses
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: `HTTP ${response.status}` }));
// Handle 400 Bad Request
if (response.status === 400) {
const errorMessage = errorData.message || 'Invalid request';
let details = '';
if (errorData.errorCode === 'TIME_RANGE_ERROR' && errorData.details?.requestedRange) {
const requested = errorData.details.requestedRange;
if (requested.requestedHours && errorData.details.maxHours) {
details = ` Requested: ${requested.requestedHours.toFixed(1)} hours, Maximum: ${errorData.details.maxHours} hours.`;
}
}
throw new Error(errorMessage + details);
}
// Handle 404 Not Found
if (response.status === 404) {
if (errorData.errorCode === 'CACHE_NOT_FOUND') {
triggerBackgroundRefresh(errorData.suggestions?.refreshEndpoint);
return {
frames: null,
error: 'location_missing',
cacheStatus: errorData.details || errorData
};
} else if (errorData.errorCode === 'TIME_RANGE_ERROR' && errorData.details?.availableRange) {
const availableRange = errorData.details.availableRange;
let rangeMessage = '';
if (errorData.suggestions?.suggestedRange) {
const suggested = errorData.suggestions.suggestedRange;
rangeMessage = ` Suggested range: ${formatDate(suggested.start)} to ${formatDate(suggested.end)}.`;
} else if (availableRange.oldest && availableRange.newest) {
rangeMessage = ` Available data: ${formatDate(availableRange.oldest)} to ${formatDate(availableRange.newest)}.`;
} else if (availableRange.totalCacheFolders > 0) {
rangeMessage = ` ${availableRange.totalCacheFolders} cache folders available.`;
}
if (errorData.suggestions?.suggestion) {
rangeMessage += ` ${errorData.suggestions.suggestion}`;
}
throw new Error(errorData.message + rangeMessage);
} else {
throw new Error(errorData.message || 'No historical data found');
}
}
throw new Error(errorData.message || 'Failed to fetch historical radar');
}
const data = await response.json();
state.historicalData = data;
// Flatten all frames from all cache folders
const allFrames = [];
data.cacheFolders.forEach(cacheFolder => {
cacheFolder.frames.forEach(frame => {
frame.cacheTimestamp = cacheFolder.cacheTimestamp;
frame.observationTime = cacheFolder.observationTime;
frame.cacheFolderName = cacheFolder.cacheFolderName;
allFrames.push(frame);
});
});
// Filter out frames without absoluteObservationTime
const invalidFrames = allFrames.filter(frame => !frame.absoluteObservationTime);
if (invalidFrames.length > 0) {
console.error('Backend returned frames missing absoluteObservationTime:', {
count: invalidFrames.length,
frames: invalidFrames.map((f, idx) => ({
index: idx,
frameIndex: f.frameIndex,
cacheFolder: f.cacheFolderName,
imageUrl: f.imageUrl
}))
});
}
const validFrames = allFrames.filter(frame => frame.absoluteObservationTime);
// Validate chronological order
let previousTime = null;
const outOfOrderFrames = [];
validFrames.forEach((frame, idx) => {
const currentTime = new Date(frame.absoluteObservationTime).getTime();
if (previousTime !== null && currentTime < previousTime) {
outOfOrderFrames.push({
index: idx,
frameIndex: frame.frameIndex,
cacheFolder: frame.cacheFolderName,
absoluteTime: frame.absoluteObservationTime,
previousTime: new Date(previousTime).toISOString(),
timeDiffMinutes: (currentTime - previousTime) / 1000 / 60
});
}
previousTime = currentTime;
});
if (outOfOrderFrames.length > 0) {
console.error('Backend returned frames out of chronological order:', {
count: outOfOrderFrames.length,
issues: outOfOrderFrames
});
}
// Re-index frames sequentially for display
validFrames.forEach((frame, idx) => {
frame.sequentialIndex = idx;
});
return { frames: validFrames, error: null };
} catch (error) {
console.error('Error fetching historical radar:', error);
if (isNetworkError(error)) {
return { frames: null, error: 'network' };
}
return { frames: [], error: null };
}
}
/**
* Fetch metadata separately
*/
export async function fetchMetadata() {
try {
const response = await fetch(`${API_BASE}/metadata`, {
signal: AbortSignal.timeout(5000)
});
if (response.ok) {
return await response.json();
}
} catch (error) {
console.debug('Could not fetch metadata:', error);
}
return null;
}
/**
* Fetch latest radar data (for latest mode)
*/
export async function fetchLatestRadar() {
const response = await fetch(API_BASE, {
signal: AbortSignal.timeout(10000)
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: `HTTP ${response.status}: ${response.statusText}` }));
if (response.status === 404) {
const errorData = await response.json().catch(() => ({}));
state.lastRefreshTime = new Date();
const refreshEndpoint = errorData.suggestions?.refreshEndpoint || errorData.refreshEndpoint;
triggerBackgroundRefresh(refreshEndpoint);
const cacheStatusData = createCacheStatusData(errorData);
const retryAfter = errorData.suggestions?.retryAfter || errorData.retryAfter || 30;
const message = errorData.message || `Cache is being generated. Please wait ${retryAfter} seconds and refresh.`;
return {
error: 'cache_generating',
cacheStatus: cacheStatusData,
message: message,
refreshEndpoint: refreshEndpoint
};
}
throw new Error(error.message || error.error || 'Failed to fetch radar data');
}
const data = await response.json();
data.isExtendedMode = false;
return { data: data, error: null };
}
/**
* Main function to fetch radar data (handles both latest and extended modes)
*/
export async function fetchRadarData() {
try {
// Extended mode
if (state.settings.timespan !== 'latest') {
// Fetch cache range first if needed
if (!state.cacheRangeInfo) {
const rangeResult = await fetchCacheRange();
if (rangeResult.error === 'network') {
throw new Error('Network error - API unavailable');
}
state.cacheRangeInfo = rangeResult.data;
}
if (!state.cacheRangeInfo || state.cacheRangeInfo.totalCacheFolders === 0) {
const refreshEndpoint = `${API_BASE.replace('/api/radar', '/api/cache')}/refresh`;
triggerBackgroundRefresh(refreshEndpoint);
// Try to fetch cache status
try {
const statusResponse = await fetch(`${API_BASE}`, {
signal: AbortSignal.timeout(5000)
});
if (statusResponse.status === 404) {
const errorData = await statusResponse.json().catch(() => ({}));
return {
error: 'no_cache_data',
cacheStatus: createCacheStatusData(errorData),
message: 'No historical cache data available yet. Cache update has been triggered in the background.'
};
}
} catch (err) {
console.debug('Could not fetch cache status:', err);
}
return {
error: 'no_cache_data',
message: 'No historical cache data available yet. Cache update has been triggered in the background.'
};
}
// Calculate time range
let startTime = null;
let endTime = new Date();
if (state.settings.timespan === 'custom') {
const startInput = document.getElementById('start-time-input');
const endInput = document.getElementById('end-time-input');
if (startInput && startInput.value) startTime = new Date(startInput.value);
if (endInput && endInput.value) endTime = new Date(endInput.value);
} else {
const hours = parseInt(state.settings.timespan.replace('h', '')) || 1;
startTime = new Date(endTime.getTime() - (hours * 60 * 60 * 1000));
}
// Fetch historical data
const result = await fetchHistoricalRadar(startTime, endTime);
if (result.error === 'network') {
throw new Error('Network error - API unavailable');
}
if (result.error === 'location_missing') {
return {
error: 'location_missing',
cacheStatus: createCacheStatusData(result.cacheStatus || {}),
message: 'No cached data found for this location. Cache update has been triggered in background.'
};
}
if (!result.frames || result.frames.length === 0) {
return {
error: 'no_frames',
message: 'No frames found for selected timespan. Try a different range or wait for more cache data.'
};
}
// Fetch metadata
let metadata = null;
try {
const metadataResponse = await fetch(API_BASE, {
signal: AbortSignal.timeout(5000)
});
if (metadataResponse.ok) {
const latestData = await metadataResponse.json();
metadata = {
weatherStation: latestData.weatherStation,
distance: latestData.distance,
observationTime: latestData.observationTime,
cacheExpiresAt: latestData.cacheExpiresAt,
nextUpdateTime: latestData.nextUpdateTime,
cacheIsValid: latestData.cacheIsValid,
isUpdating: latestData.isUpdating
};
}
} catch (err) {
console.debug('Could not fetch metadata for extended mode:', err);
}
// Create response object
const newestCacheFolder = state.historicalData.cacheFolders[state.historicalData.cacheFolders.length - 1];
return {
data: {
frames: result.frames,
lastUpdated: endTime.toISOString(),
observationTime: metadata?.observationTime || newestCacheFolder?.observationTime || endTime.toISOString(),
forecastTime: endTime.toISOString(),
weatherStation: metadata?.weatherStation || null,
distance: metadata?.distance || null,
cacheIsValid: metadata?.cacheIsValid ?? true,
cacheExpiresAt: metadata?.cacheExpiresAt || endTime.toISOString(),
isUpdating: metadata?.isUpdating || false,
nextUpdateTime: metadata?.nextUpdateTime || endTime.toISOString(),
totalFrames: result.frames.length,
isExtendedMode: true
},
error: null
};
} else {
// Latest mode
const result = await fetchLatestRadar();
if (result.error) {
return result;
}
return result;
}
} catch (error) {
console.error('Error fetching radar data:', error);
if (isNetworkError(error)) {
return { error: 'network', originalError: error };
}
return { error: 'unknown', message: error.message };
}
}
+203
View File
@@ -0,0 +1,203 @@
// Main application entry point
import { loadSettings, saveSettings, updateSettingsUI, applySettings } from './settings.js';
import { fetchRadarData } from './api-client.js';
import { updateUI, updateRelativeTimes } from './ui-updater.js';
import { showError, showNoFramesMessage, clearError } from './ui-updater.js';
import { handleApiDown, resetApiRetryState } from './error-handler.js';
import { buildFrameControls, showFrame, findFrameToShowAfterRefresh } from './frame-navigation.js';
import { play, pause, togglePlay, previousFrame, nextFrame } from './slideshow.js';
import { setupKeyboardNavigation } from './keyboard.js';
import { state } from './state.js';
import { isNetworkError } from './utils.js';
/**
* Settings modal management
*/
function showSettings() {
const modal = document.getElementById('settings-modal');
if (modal) modal.style.display = 'flex';
}
function hideSettings() {
const modal = document.getElementById('settings-modal');
if (modal) modal.style.display = 'none';
}
/**
* Auto-refresh data
*/
async function refreshData() {
// Don't clear error if API is down (keep retry message visible)
if (!state.isApiDown) {
clearError();
}
const result = await fetchRadarData();
if (result.error) {
if (result.error === 'network') {
handleApiDown(result.originalError || new Error('Network error'));
return;
}
// Handle other errors
if (result.error === 'cache_generating') {
updateUI(result.cacheStatus);
showNoFramesMessage(result.message, result.refreshEndpoint);
} else if (result.error === 'location_missing' || result.error === 'no_cache_data' || result.error === 'no_frames') {
if (result.cacheStatus) {
updateUI(result.cacheStatus);
}
showNoFramesMessage(result.message, result.refreshEndpoint);
} else {
showError(result.message || 'An error occurred');
}
return;
}
if (result.data) {
state.lastRefreshTime = new Date();
resetApiRetryState();
updateUI(result.data);
// Build frame controls after UI update
buildFrameControls();
// Find and show appropriate frame
const frameToShow = findFrameToShowAfterRefresh();
showFrame(frameToShow);
// Restart auto-refresh if it was stopped
if (!state.refreshInterval && !state.isApiDown) {
state.refreshInterval = setInterval(refreshData, state.settings.refreshInterval * 1000);
}
// Restart play if it was playing before refresh
const wasPlaying = state.isPlaying;
if (wasPlaying && result.data.frames && result.data.frames.length > 0) {
setTimeout(() => {
if (!state.isPlaying) {
play();
}
}, 100);
} else if (state.settings.autoPlay && !state.isPlaying && result.data.frames && result.data.frames.length > 0) {
// Auto-play on initial load only
setTimeout(() => play(), 500);
}
}
}
/**
* Initialize application
*/
async function init() {
// Load settings from cookies
loadSettings();
// Set up event listeners
const playBtn = document.getElementById('play-btn');
const prevBtn = document.getElementById('prev-btn');
const nextBtn = document.getElementById('next-btn');
if (playBtn) playBtn.addEventListener('click', togglePlay);
if (prevBtn) prevBtn.addEventListener('click', previousFrame);
if (nextBtn) nextBtn.addEventListener('click', nextFrame);
// Settings button
const settingsBtn = document.getElementById('settings-btn-header');
if (settingsBtn) {
settingsBtn.addEventListener('click', showSettings);
}
// Settings modal handlers
const saveSettingsBtn = document.getElementById('save-settings-btn');
if (saveSettingsBtn) {
saveSettingsBtn.addEventListener('click', () => {
const frameIntervalValue = parseFloat(document.getElementById('frame-interval-input').value);
state.settings.frameInterval = (frameIntervalValue > 0) ? frameIntervalValue : 2.0;
const refreshIntervalValue = parseInt(document.getElementById('refresh-interval-input').value);
state.settings.refreshInterval = (refreshIntervalValue >= 5) ? refreshIntervalValue : 30;
state.settings.autoPlay = document.getElementById('auto-play-input').checked;
state.settings.timespan = document.getElementById('timespan-select').value;
if (state.settings.timespan === 'custom') {
const startInput = document.getElementById('start-time-input');
const endInput = document.getElementById('end-time-input');
if (startInput && startInput.value) {
state.settings.customStartTime = startInput.value;
}
if (endInput && endInput.value) {
state.settings.customEndTime = endInput.value;
}
}
saveSettings();
hideSettings();
// Apply settings and restart refresh interval
applySettings();
if (state.refreshInterval) {
clearInterval(state.refreshInterval);
state.refreshInterval = null;
}
// Reload data with new timespan
refreshData();
});
}
// Timespan select change handler
const timespanSelect = document.getElementById('timespan-select');
if (timespanSelect) {
timespanSelect.addEventListener('change', (e) => {
state.settings.timespan = e.target.value;
const customSection = document.getElementById('custom-range-section');
if (customSection) {
customSection.style.display = e.target.value === 'custom' ? 'block' : 'none';
}
});
}
// Cancel settings button
const cancelSettingsBtn = document.getElementById('cancel-settings-btn');
if (cancelSettingsBtn) {
cancelSettingsBtn.addEventListener('click', () => {
updateSettingsUI(); // Reset to current settings
hideSettings();
});
}
// Close button handler
const closeSettingsBtn = document.getElementById('close-settings-btn');
if (closeSettingsBtn) {
closeSettingsBtn.addEventListener('click', hideSettings);
}
// Close modal on background click
const settingsModal = document.getElementById('settings-modal');
if (settingsModal) {
settingsModal.addEventListener('click', (e) => {
if (e.target.id === 'settings-modal') {
hideSettings();
}
});
}
// Setup keyboard navigation
setupKeyboardNavigation();
// Initial load
await refreshData();
// Auto-refresh based on settings (only if API is up)
if (!state.isApiDown) {
state.refreshInterval = setInterval(refreshData, state.settings.refreshInterval * 1000);
}
// Update relative times every second
setInterval(updateRelativeTimes, 1000);
}
// Start when page loads
init();
+15
View File
@@ -0,0 +1,15 @@
// Configuration constants
export const MAX_RETRY_ATTEMPTS = 10;
export const RETRY_DELAY_MS = 5000; // 5 seconds between retries
// Get API base URL from global variable set by Razor view
export const API_BASE = window.API_BASE || '';
// Default settings
export const DEFAULT_SETTINGS = {
frameInterval: 2.0, // seconds between frames
refreshInterval: 30, // seconds between API refreshes
autoPlay: true, // auto-play on load
timespan: 'latest' // 'latest', '1h', '3h', '6h', '12h', '24h', 'custom'
};
+68
View File
@@ -0,0 +1,68 @@
// Error handling and API retry logic
import { MAX_RETRY_ATTEMPTS, RETRY_DELAY_MS } from './config.js';
import { state } from './state.js';
import { updateApiStatus, showError, clearError } from './ui-updater.js';
import { fetchRadarData } from './api-client.js';
import { updateUI } from './ui-updater.js';
/**
* Reset API retry state when connection is restored
*/
export function resetApiRetryState() {
if (state.isApiDown) {
state.isApiDown = false;
state.retryAttempts = 0;
clearError();
if (state.retryInterval) {
clearInterval(state.retryInterval);
state.retryInterval = null;
}
updateApiStatus('connected');
}
}
/**
* Handle API being down
*/
export function handleApiDown(error) {
if (!state.isApiDown) {
state.isApiDown = true;
state.retryAttempts = 0;
updateApiStatus('disconnected');
}
state.retryAttempts++;
showError(`API connection lost. Retrying... (Attempt ${state.retryAttempts}/${MAX_RETRY_ATTEMPTS})`);
// Stop auto-refresh while retrying
if (state.refreshInterval) {
clearInterval(state.refreshInterval);
state.refreshInterval = null;
}
// Start retry mechanism
if (!state.retryInterval && state.retryAttempts < MAX_RETRY_ATTEMPTS) {
state.retryInterval = setInterval(async () => {
if (state.retryAttempts >= MAX_RETRY_ATTEMPTS) {
clearInterval(state.retryInterval);
state.retryInterval = null;
showError('API connection failed after multiple attempts. Please check if the service is running.');
updateApiStatus('failed');
return;
}
state.retryAttempts++;
showError(`API connection lost. Retrying... (Attempt ${state.retryAttempts}/${MAX_RETRY_ATTEMPTS})`);
// Try to fetch data
const result = await fetchRadarData();
if (result.data) {
updateUI(result.data);
}
}, RETRY_DELAY_MS);
} else if (state.retryAttempts >= MAX_RETRY_ATTEMPTS) {
showError('API connection failed after multiple attempts. Please check if the service is running.');
updateApiStatus('failed');
}
}
+305
View File
@@ -0,0 +1,305 @@
// Frame navigation and display
import { formatDate, formatFrameTime, getMinutesAgo, preloadImages } from './utils.js';
import { state } from './state.js';
/**
* Calculate jump amount for frame navigation
* Returns 10% of total frames (min 1, max 50) for extended mode, 0 otherwise
*/
export function getJumpAmount() {
if (!state.isExtendedMode || state.frames.length === 0) {
return 0;
}
return Math.max(1, Math.min(50, Math.round(state.frames.length / 10)));
}
/**
* Build frame controls HTML and attach event listeners
*/
export function buildFrameControls() {
const controlsEl = document.getElementById('frame-controls');
if (!controlsEl) return;
// Calculate jump amount - 10% of total frames, minimum 1, maximum 50
// Only used in extended mode (timeseries)
const jumpAmount = getJumpAmount();
// Build jump buttons HTML only for extended mode
const prevJumpButtonHtml = state.isExtendedMode && jumpAmount > 0
? `<button class="frame-nav-btn" id="frame-prev-jump-btn" title="Go back ${jumpAmount} frames" aria-label="Go back ${jumpAmount} frames">-${jumpAmount}</button>`
: '';
const nextJumpButtonHtml = state.isExtendedMode && jumpAmount > 0
? `<button class="frame-nav-btn" id="frame-next-jump-btn" title="Go forward ${jumpAmount} frames" aria-label="Go forward ${jumpAmount} frames">+${jumpAmount}</button>`
: '';
controlsEl.innerHTML = `
<div class="frame-slider-container">
<div class="frame-slider-wrapper">
<button class="frame-nav-btn" id="frame-first-btn" title="First frame" aria-label="First frame">⏮</button>
${prevJumpButtonHtml}
<input type="range" class="frame-slider" id="frame-slider" min="0" max="${state.frames.length - 1}" value="${state.currentFrameIndex}" aria-label="Frame slider">
${nextJumpButtonHtml}
<button class="frame-nav-btn" id="frame-last-btn" title="Last frame" aria-label="Last frame">⏭</button>
</div>
<div class="frame-info-display">
<div class="frame-info-item">
<span class="frame-info-label">Frame:</span>
<input type="number" class="frame-jump-input" id="frame-jump-input" min="0" max="${state.frames.length - 1}" value="0">
<span class="frame-info-value">/ ${state.frames.length - 1}</span>
</div>
<div class="frame-info-item">
<span class="frame-info-label">Time:</span>
<span class="frame-info-value" id="current-frame-time">-</span>
</div>
<div class="frame-info-item">
<span class="frame-info-label">Progress:</span>
<span class="frame-info-value" id="frame-progress">0%</span>
</div>
</div>
</div>
`;
// Attach event listeners
const firstBtn = document.getElementById('frame-first-btn');
const prevJumpBtn = document.getElementById('frame-prev-jump-btn');
const nextJumpBtn = document.getElementById('frame-next-jump-btn');
const lastBtn = document.getElementById('frame-last-btn');
const slider = document.getElementById('frame-slider');
const jumpInput = document.getElementById('frame-jump-input');
if (firstBtn) firstBtn.addEventListener('click', () => showFrame(0));
if (prevJumpBtn && jumpAmount > 0) {
prevJumpBtn.addEventListener('click', () => jumpFrame(-jumpAmount));
}
if (nextJumpBtn && jumpAmount > 0) {
nextJumpBtn.addEventListener('click', () => jumpFrame(jumpAmount));
}
if (lastBtn) lastBtn.addEventListener('click', () => showFrame(state.frames.length - 1));
if (slider) {
slider.addEventListener('input', (e) => updateFrameFromSlider(parseInt(e.target.value)));
}
if (jumpInput) {
jumpInput.addEventListener('change', (e) => jumpToFrame(parseInt(e.target.value)));
jumpInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') jumpToFrame(parseInt(e.target.value));
});
}
// Update initial display
updateFrameSliderInfo(state.currentFrameIndex);
// Preload all frame images
preloadImages(state.frames);
}
/**
* Update frame slider info display
*/
export function updateFrameSliderInfo(index) {
if (index < 0 || index >= state.frames.length) return;
const frame = state.frames[index];
const progress = Math.round(((index + 1) / state.frames.length) * 100);
// Update jump input
const jumpInput = document.getElementById('frame-jump-input');
if (jumpInput) jumpInput.value = index;
// Update slider
const slider = document.getElementById('frame-slider');
if (slider) slider.value = index;
// Update frame number display
const frameNumDisplay = document.getElementById('current-frame-num-display');
if (frameNumDisplay) frameNumDisplay.textContent = index + 1;
// Update time display
const timeDisplay = document.getElementById('current-frame-time');
if (timeDisplay) {
timeDisplay.textContent = formatFrameTime(frame, state.isExtendedMode);
}
// Update progress
const progressDisplay = document.getElementById('frame-progress');
if (progressDisplay) progressDisplay.textContent = `${progress}%`;
// Update navigation buttons
const firstBtn = document.getElementById('frame-first-btn');
const prevJumpBtn = document.getElementById('frame-prev-jump-btn');
const nextJumpBtn = document.getElementById('frame-next-jump-btn');
const lastBtn = document.getElementById('frame-last-btn');
if (firstBtn) firstBtn.disabled = index === 0;
if (prevJumpBtn) prevJumpBtn.disabled = index === 0;
if (nextJumpBtn) nextJumpBtn.disabled = index >= state.frames.length - 1;
if (lastBtn) lastBtn.disabled = index >= state.frames.length - 1;
}
let sliderUpdateTimeout;
/**
* Update frame from slider (with debouncing)
*/
export function updateFrameFromSlider(value) {
clearTimeout(sliderUpdateTimeout);
sliderUpdateTimeout = setTimeout(() => {
showFrame(value);
}, 50);
}
/**
* Jump to specific frame number
*/
export function jumpToFrame(frameNum) {
const index = Math.max(0, Math.min(state.frames.length - 1, frameNum));
showFrame(index);
}
/**
* Jump forward/backward by N frames
*/
export function jumpFrame(offset) {
const newIndex = Math.max(0, Math.min(state.frames.length - 1, state.currentFrameIndex + offset));
showFrame(newIndex);
}
/**
* Build alt text for frame image
*/
function buildFrameAltText(frame, index) {
if (state.isExtendedMode) {
const frameNum = frame.sequentialIndex !== undefined ? frame.sequentialIndex : index;
const timeInfo = frame.absoluteObservationTime
? formatDate(frame.absoluteObservationTime)
: (frame.cacheTimestamp ? formatDate(frame.cacheTimestamp) : '');
return `Radar frame ${frameNum}${timeInfo ? ' (' + timeInfo + ')' : ''}`;
} else {
const minutesAgo = getMinutesAgo(frame.absoluteObservationTime);
if (minutesAgo !== null) {
return `Radar frame ${frame.frameIndex} (${minutesAgo} minutes ago)`;
} else {
// Fall back to formatted date if minutes ago calculation fails
const timeInfo = frame.absoluteObservationTime ? formatDate(frame.absoluteObservationTime) : '';
return `Radar frame ${frame.frameIndex}${timeInfo ? ' (' + timeInfo + ')' : ''}`;
}
}
}
/**
* Build frame info text
*/
function buildFrameInfoText(frame, index) {
if (state.isExtendedMode) {
const frameNum = frame.sequentialIndex !== undefined ? frame.sequentialIndex : index;
const timeInfo = frame.absoluteObservationTime
? formatDate(frame.absoluteObservationTime)
: (frame.cacheTimestamp ? formatDate(frame.cacheTimestamp) : '');
return `Frame ${frameNum} of ${state.frames.length - 1}${timeInfo ? ' • ' + timeInfo : ''}`;
} else {
const minutesAgo = getMinutesAgo(frame.absoluteObservationTime);
if (minutesAgo !== null) {
return `Frame ${frame.frameIndex} of ${state.frames.length - 1} • ${minutesAgo} minutes ago`;
} else {
// Fall back to formatted date if minutes ago calculation fails
const timeInfo = frame.absoluteObservationTime ? formatDate(frame.absoluteObservationTime) : '';
return `Frame ${frame.frameIndex} of ${state.frames.length - 1}${timeInfo ? ' • ' + timeInfo : ''}`;
}
}
}
/**
* Show specific frame
*/
export function showFrame(index) {
if (index < 0 || index >= state.frames.length) {
console.error(`showFrame: Invalid index ${index}, frames.length = ${state.frames.length}`);
return;
}
if (!state.frames[index]) {
console.error(`showFrame: Frame at index ${index} is undefined`);
return;
}
state.currentFrameIndex = index;
const frame = state.frames[index];
// Update slider info
updateFrameSliderInfo(index);
// Update active button state
document.querySelectorAll('.frame-btn').forEach(btn => {
btn.classList.remove('active');
if (parseInt(btn.getAttribute('data-frame')) === index) {
btn.classList.add('active');
}
});
// Update image
const imgEl = document.getElementById('radar-image');
const loadingEl = document.getElementById('loading');
const altText = buildFrameAltText(frame, index);
// Check if image is already loaded (from preload)
const preloadedImg = new Image();
preloadedImg.onload = () => {
if (imgEl) {
imgEl.src = frame.imageUrl;
imgEl.alt = altText;
}
if (loadingEl) loadingEl.style.display = 'none';
if (imgEl) imgEl.style.display = 'block';
};
preloadedImg.onerror = () => {
if (imgEl) {
imgEl.src = frame.imageUrl;
imgEl.alt = altText;
}
};
preloadedImg.src = frame.imageUrl;
// Update frame info
const frameInfoEl = document.getElementById('frame-info');
if (frameInfoEl) {
frameInfoEl.textContent = buildFrameInfoText(frame, index);
}
}
/**
* Find frame to show after refresh (preserves position)
*/
export function findFrameToShowAfterRefresh() {
if (state.frames.length === 0) return 0;
const previousFrameIndex = state.currentFrameIndex;
if (previousFrameIndex < 0 || !state.radarData?.frames) return 0;
const previousFrame = state.radarData.frames[previousFrameIndex];
if (!previousFrame) return 0;
if (previousFrame.absoluteObservationTime) {
// Find frame with same absolute observation time
const matchingIndex = state.frames.findIndex(f =>
f.absoluteObservationTime === previousFrame.absoluteObservationTime
);
if (matchingIndex >= 0) return matchingIndex;
// Find closest frame by time
const previousTime = new Date(previousFrame.absoluteObservationTime).getTime();
let closestIndex = 0;
let closestDiff = Infinity;
state.frames.forEach((f, idx) => {
if (f.absoluteObservationTime) {
const diff = Math.abs(new Date(f.absoluteObservationTime).getTime() - previousTime);
if (diff < closestDiff) {
closestDiff = diff;
closestIndex = idx;
}
}
});
return closestIndex;
}
// Fallback: keep same index if valid
return previousFrameIndex < state.frames.length ? previousFrameIndex : 0;
}
+59
View File
@@ -0,0 +1,59 @@
// Keyboard navigation
import { state } from './state.js';
import { showFrame, jumpFrame, getJumpAmount } from './frame-navigation.js';
import { togglePlay } from './slideshow.js';
/**
* Setup keyboard navigation
*/
export function setupKeyboardNavigation() {
document.addEventListener('keydown', (e) => {
// Don't handle keyboard shortcuts if user is typing in an input
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
return;
}
// Arrow keys for frame navigation
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
if (e.key === 'ArrowLeft') {
if (e.shiftKey) {
// Shift+Left: jump back by proportional amount (only in extended mode)
const jumpAmount = getJumpAmount();
if (jumpAmount > 0) {
jumpFrame(-jumpAmount);
}
} else {
showFrame(Math.max(0, state.currentFrameIndex - 1)); // Left: previous frame
}
} else {
if (e.shiftKey) {
// Shift+Right: jump forward by proportional amount (only in extended mode)
const jumpAmount = getJumpAmount();
if (jumpAmount > 0) {
jumpFrame(jumpAmount);
}
} else {
showFrame(Math.min(state.frames.length - 1, state.currentFrameIndex + 1)); // Right: next frame
}
}
}
// Home/End for first/last frame
if (e.key === 'Home') {
e.preventDefault();
showFrame(0);
}
if (e.key === 'End') {
e.preventDefault();
showFrame(state.frames.length - 1);
}
// Spacebar for play/pause
if (e.key === ' ' && state.frames.length > 0) {
e.preventDefault();
togglePlay();
}
});
}
+62
View File
@@ -0,0 +1,62 @@
// Settings management
import { getCookie, setCookie } from './utils.js';
import { state } from './state.js';
import { fetchCacheRange } from './api-client.js';
import { pause, play } from './slideshow.js';
/**
* Load settings from cookies
*/
export function loadSettings() {
const saved = getCookie('radarTestSettings');
if (saved) {
state.settings = { ...state.settings, ...saved };
}
updateSettingsUI();
}
/**
* Save settings to cookies
*/
export function saveSettings() {
setCookie('radarTestSettings', state.settings);
updateSettingsUI();
applySettings();
}
/**
* Update settings UI elements
*/
export function updateSettingsUI() {
document.getElementById('frame-interval-input').value = state.settings.frameInterval;
document.getElementById('refresh-interval-input').value = state.settings.refreshInterval;
document.getElementById('auto-play-input').checked = state.settings.autoPlay;
document.getElementById('timespan-select').value = state.settings.timespan || 'latest';
// Show/hide custom range section
const customSection = document.getElementById('custom-range-section');
if (customSection) {
customSection.style.display = state.settings.timespan === 'custom' ? 'block' : 'none';
}
// Load cache range info when settings modal opens
fetchCacheRange();
}
/**
* Apply settings (restart intervals if needed)
*/
export function applySettings() {
// Restart play interval if playing
if (state.isPlaying) {
pause();
play();
}
// Restart refresh interval (will be handled by refreshData caller)
if (state.refreshInterval) {
clearInterval(state.refreshInterval);
state.refreshInterval = null;
}
}
+67
View File
@@ -0,0 +1,67 @@
// Slideshow controls (play/pause)
import { state } from './state.js';
import { showFrame } from './frame-navigation.js';
/**
* Play slideshow
*/
export function play() {
if (state.frames.length === 0) return;
state.isPlaying = true;
const playBtn = document.getElementById('play-btn');
if (playBtn) playBtn.textContent = '⏸ Pause';
if (state.playInterval) {
clearInterval(state.playInterval);
}
state.playInterval = setInterval(() => {
state.currentFrameIndex = (state.currentFrameIndex + 1) % state.frames.length;
showFrame(state.currentFrameIndex);
}, state.settings.frameInterval * 1000);
}
/**
* Pause slideshow
*/
export function pause() {
state.isPlaying = false;
const playBtn = document.getElementById('play-btn');
if (playBtn) playBtn.textContent = '▶ Play';
if (state.playInterval) {
clearInterval(state.playInterval);
state.playInterval = null;
}
}
/**
* Toggle play/pause
*/
export function togglePlay() {
if (state.isPlaying) {
pause();
} else {
play();
}
}
/**
* Go to previous frame
*/
export function previousFrame() {
pause();
const newIndex = state.currentFrameIndex > 0 ? state.currentFrameIndex - 1 : state.frames.length - 1;
showFrame(newIndex);
}
/**
* Go to next frame
*/
export function nextFrame() {
pause();
const newIndex = (state.currentFrameIndex + 1) % state.frames.length;
showFrame(newIndex);
}
+41
View File
@@ -0,0 +1,41 @@
// Application state management
import { DEFAULT_SETTINGS } from './config.js';
// Export a mutable state object - properties can be mutated even though the object binding is read-only
export const state = {
currentFrameIndex: 0,
frames: [],
radarData: null,
playInterval: null,
isPlaying: false,
lastRefreshTime: null,
refreshInterval: null,
nextClientCheckTime: null,
retryInterval: null,
isApiDown: false,
retryAttempts: 0,
cacheRangeInfo: null,
historicalData: null,
isExtendedMode: false,
settings: { ...DEFAULT_SETTINGS }
};
// Reset state (for testing/debugging)
export function resetState() {
state.currentFrameIndex = 0;
state.frames = [];
state.radarData = null;
state.playInterval = null;
state.isPlaying = false;
state.lastRefreshTime = null;
state.refreshInterval = null;
state.nextClientCheckTime = null;
state.retryInterval = null;
state.isApiDown = false;
state.retryAttempts = 0;
state.cacheRangeInfo = null;
state.historicalData = null;
state.isExtendedMode = false;
state.settings = { ...DEFAULT_SETTINGS };
}
+281
View File
@@ -0,0 +1,281 @@
// UI update functions
import { formatDate, getRelativeTime, setElementText, setElementHTML, getElement } from './utils.js';
import { state } from './state.js';
/**
* Update API connection status indicator
*/
export function updateApiStatus(status) {
const refreshStatusEl = getElement('refresh-status');
const refreshIndicatorEl = getElement('refresh-indicator');
if (!refreshStatusEl || !refreshIndicatorEl) return;
if (status === 'connected') {
refreshStatusEl.textContent = 'Active';
refreshIndicatorEl.style.background = '#28a745';
refreshIndicatorEl.style.animation = 'pulse 2s infinite';
} else if (status === 'disconnected') {
refreshStatusEl.textContent = 'Reconnecting...';
refreshIndicatorEl.style.background = '#ffc107';
refreshIndicatorEl.style.animation = 'pulse 1s infinite';
} else if (status === 'failed') {
refreshStatusEl.textContent = 'Connection Failed';
refreshIndicatorEl.style.background = '#dc3545';
refreshIndicatorEl.style.animation = 'none';
}
}
/**
* Show error message
*/
export function showError(message) {
const container = getElement('error-container');
if (!container) return;
const isRetrying = message.includes('Retrying');
const errorClass = isRetrying ? 'error retrying' : 'error';
container.innerHTML = `<div class="${errorClass}">${isRetrying ? '⚠️ ' : '❌ Error: '}${message}</div>`;
}
/**
* Show graceful "no frames" message
*/
export function showNoFramesMessage(message, refreshEndpoint = null) {
const container = getElement('error-container');
if (!container) return;
let html = `<div class="error retrying" style="background: #fff3cd; color: #856404; border-left-color: #ffc107;">
<div style="margin-bottom: 10px;">⏳ ${message}</div>`;
if (refreshEndpoint) {
html += `<button class="play-btn" onclick="window.location.reload()" style="margin-top: 10px; padding: 8px 16px; font-size: 0.9em;">
🔄 Refresh Now
</button>`;
}
html += `</div>`;
container.innerHTML = html;
// Hide loading and show message in image container
const loadingEl = getElement('loading');
const imageEl = getElement('radar-image');
if (loadingEl) loadingEl.textContent = message;
if (imageEl) imageEl.style.display = 'none';
}
/**
* Clear error message
*/
export function clearError() {
const container = getElement('error-container');
if (container) container.innerHTML = '';
}
/**
* Update relative times periodically
*/
export function updateRelativeTimes() {
if (!state.radarData) return;
const expiresEl = getElement('cache-expires-relative');
const nextUpdateEl = getElement('next-update-relative');
const updateStatusDetailEl = getElement('update-status-detail');
const nextCheckEl = getElement('next-client-check');
const estimationNoteEl = getElement('estimation-note');
if (state.radarData.cacheExpiresAt && expiresEl) {
expiresEl.textContent = getRelativeTime(state.radarData.cacheExpiresAt);
}
if (state.radarData.nextUpdateTime && nextUpdateEl) {
nextUpdateEl.textContent = getRelativeTime(state.radarData.nextUpdateTime);
}
// Update update status detail with relative time
if (updateStatusDetailEl) {
if (state.radarData.isUpdating && state.radarData.nextUpdateTime) {
const relativeTime = getRelativeTime(state.radarData.nextUpdateTime);
updateStatusDetailEl.textContent = 'Update in progress • Estimated completion: ' + formatDate(state.radarData.nextUpdateTime) + ' (' + relativeTime + ')';
updateStatusDetailEl.title = 'Estimate is based on historical metrics and current progress. Accuracy improves as more updates complete.';
if (estimationNoteEl) estimationNoteEl.style.display = 'inline';
} else if (!state.radarData.cacheIsValid && !state.radarData.isUpdating && state.radarData.nextUpdateTime) {
const relativeTime = getRelativeTime(state.radarData.nextUpdateTime);
updateStatusDetailEl.textContent = 'Update scheduled: ' + formatDate(state.radarData.nextUpdateTime) + ' (' + relativeTime + ')';
updateStatusDetailEl.title = 'Estimated time when cache update will be triggered or completed. Based on metrics from previous updates.';
if (estimationNoteEl) estimationNoteEl.style.display = 'inline';
} else if (state.radarData.cacheIsValid) {
updateStatusDetailEl.textContent = 'Cache is valid, no update needed';
updateStatusDetailEl.title = '';
if (estimationNoteEl) estimationNoteEl.style.display = 'none';
} else {
if (estimationNoteEl) estimationNoteEl.style.display = 'none';
}
}
// Update next client check time
if (state.nextClientCheckTime && nextCheckEl) {
nextCheckEl.textContent = 'Next check: ' + formatDate(state.nextClientCheckTime.toISOString()) + ' (' + getRelativeTime(state.nextClientCheckTime.toISOString()) + ')';
}
}
/**
* Update status cards with cache update information
*/
export function updateStatusForCacheUpdate(retryAfter) {
const statusEl = getElement('cache-status');
const updateStatusDetailEl = getElement('update-status-detail');
if (statusEl) {
statusEl.innerHTML = 'Generating <span class="status-badge status-updating">IN PROGRESS</span>';
}
if (updateStatusDetailEl) {
if (retryAfter) {
const estimatedTime = new Date(Date.now() + (retryAfter * 1000));
updateStatusDetailEl.textContent = `Cache generation in progress. Estimated ready in ${retryAfter} seconds.`;
setElementText('next-update', formatDate(estimatedTime.toISOString()));
} else {
updateStatusDetailEl.textContent = 'Cache generation in progress, please wait...';
setElementText('next-update', '-');
}
}
// Set other fields to indicate cache is being generated
setElementText('observation-time', '-');
setElementText('cache-expires', '-');
setElementText('weather-station', '-');
setElementText('distance', '-');
// Update last refresh
if (state.lastRefreshTime) {
setElementText('last-refresh', 'Last checked: ' + formatDate(state.lastRefreshTime.toISOString()));
// Calculate and display next client check time
state.nextClientCheckTime = new Date(state.lastRefreshTime.getTime() + (state.settings.refreshInterval * 1000));
const nextCheckEl = getElement('next-client-check');
if (nextCheckEl) {
nextCheckEl.textContent = 'Next check: ' + formatDate(state.nextClientCheckTime.toISOString()) + ' (' + getRelativeTime(state.nextClientCheckTime.toISOString()) + ')';
}
}
}
/**
* Update UI with radar data (main update function)
*/
export function updateUI(data) {
// Update cache status even if no frames are available
if (data) {
const statusEl = getElement('cache-status');
if (statusEl && (data.isUpdating !== undefined || data.cacheIsValid !== undefined)) {
let statusHtml = '';
if (data.isUpdating) {
statusHtml = 'Updating <span class="status-badge status-updating">IN PROGRESS</span>';
} else if (data.cacheIsValid) {
statusHtml = 'Valid <span class="status-badge status-valid">ACTIVE</span>';
} else {
statusHtml = 'Invalid <span class="status-badge status-invalid">EXPIRED</span>';
}
statusEl.innerHTML = statusHtml;
}
// Update update status detail
const updateStatusDetailEl = getElement('update-status-detail');
const estimationNoteEl = getElement('estimation-note');
if (updateStatusDetailEl && (data.isUpdating !== undefined || data.cacheIsValid !== undefined)) {
if (data.isUpdating) {
if (data.nextUpdateTime) {
const relativeTime = getRelativeTime(data.nextUpdateTime);
updateStatusDetailEl.textContent = 'Update in progress • Estimated completion: ' + formatDate(data.nextUpdateTime) + ' (' + relativeTime + ')';
updateStatusDetailEl.title = 'Estimate is based on historical metrics and current progress. Accuracy improves as more updates complete.';
if (estimationNoteEl) estimationNoteEl.style.display = 'inline';
} else {
updateStatusDetailEl.textContent = 'Update in progress, please wait...';
updateStatusDetailEl.title = 'Cache update is in progress. Completion time will be estimated once progress tracking begins.';
if (estimationNoteEl) estimationNoteEl.style.display = 'none';
}
} else if (data.cacheIsValid) {
updateStatusDetailEl.textContent = 'Cache is valid, no update needed';
updateStatusDetailEl.title = '';
if (estimationNoteEl) estimationNoteEl.style.display = 'none';
} else {
if (data.nextUpdateTime) {
const relativeTime = getRelativeTime(data.nextUpdateTime);
updateStatusDetailEl.textContent = 'Update scheduled: ' + formatDate(data.nextUpdateTime) + ' (' + relativeTime + ')';
updateStatusDetailEl.title = 'Estimated time when cache update will be triggered or completed. Based on metrics from previous updates.';
if (estimationNoteEl) estimationNoteEl.style.display = 'inline';
} else {
updateStatusDetailEl.textContent = 'Update will be triggered by background service';
updateStatusDetailEl.title = 'The background cache management service will check and update the cache periodically.';
if (estimationNoteEl) estimationNoteEl.style.display = 'none';
}
}
}
// Update other metadata fields
if (data.observationTime) setElementText('observation-time', formatDate(data.observationTime));
if (data.cacheExpiresAt) setElementText('cache-expires', formatDate(data.cacheExpiresAt));
if (data.nextUpdateTime) setElementText('next-update', formatDate(data.nextUpdateTime));
if (data.weatherStation !== undefined) setElementText('weather-station', data.weatherStation || '-');
if (data.distance !== undefined) setElementText('distance', data.distance || '-');
}
// Handle no frames case
if (!data || !data.frames || data.frames.length === 0) {
const currentStatus = getElement('cache-status')?.innerHTML;
if (!currentStatus || currentStatus === 'Checking...' || currentStatus.trim() === '') {
const statusEl = getElement('cache-status');
const updateStatusDetailEl = getElement('update-status-detail');
if (statusEl) statusEl.innerHTML = 'Generating <span class="status-badge status-updating">IN PROGRESS</span>';
if (updateStatusDetailEl) updateStatusDetailEl.textContent = 'Cache generation in progress, please wait...';
}
showNoFramesMessage('No radar frames available yet. Cache is being generated in the background. Please wait a moment and refresh.');
state.frames = [];
const controlsEl = getElement('frame-controls');
if (controlsEl) controlsEl.innerHTML = '';
setElementText('play-btn', '▶ Play');
const playBtn = getElement('play-btn');
const prevBtn = getElement('prev-btn');
const nextBtn = getElement('next-btn');
if (playBtn) playBtn.disabled = true;
if (prevBtn) prevBtn.disabled = true;
if (nextBtn) nextBtn.disabled = true;
setElementText('frame-info', 'Waiting for cache to be generated...');
return;
}
// Update state
state.radarData = data;
state.isExtendedMode = data.isExtendedMode || false;
// Enable controls
const playBtn = getElement('play-btn');
const prevBtn = getElement('prev-btn');
const nextBtn = getElement('next-btn');
if (playBtn) playBtn.disabled = false;
if (prevBtn) prevBtn.disabled = false;
if (nextBtn) nextBtn.disabled = false;
// Sort frames appropriately
if (state.isExtendedMode) {
state.frames = data.frames;
} else {
state.frames = data.frames.sort((a, b) => a.frameIndex - b.frameIndex);
}
// Update metadata fields (redundant updates removed since already done above)
// Update last refresh
if (state.lastRefreshTime) {
setElementText('last-refresh', 'Last checked: ' + formatDate(state.lastRefreshTime.toISOString()));
state.nextClientCheckTime = new Date(state.lastRefreshTime.getTime() + (state.settings.refreshInterval * 1000));
const nextCheckEl = getElement('next-client-check');
if (nextCheckEl) {
nextCheckEl.textContent = 'Next check: ' + formatDate(state.nextClientCheckTime.toISOString()) + ' (' + getRelativeTime(state.nextClientCheckTime.toISOString()) + ')';
}
}
// Frame controls will be built by the caller (to avoid circular dependencies)
}
+184
View File
@@ -0,0 +1,184 @@
// Utility functions
// Cookie management
export function setCookie(name, value, days = 365) {
const expires = new Date();
expires.setTime(expires.getTime() + (days * 24 * 60 * 60 * 1000));
document.cookie = `${name}=${encodeURIComponent(JSON.stringify(value))};expires=${expires.toUTCString()};path=/`;
}
export function getCookie(name) {
const nameEQ = name + "=";
const ca = document.cookie.split(';');
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) === ' ') c = c.substring(1, c.length);
if (c.indexOf(nameEQ) === 0) {
try {
return JSON.parse(decodeURIComponent(c.substring(nameEQ.length, c.length)));
} catch (e) {
return null;
}
}
}
return null;
}
// Date formatting utilities
export function formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleString('en-AU', {
timeZone: 'Australia/Brisbane',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
export function getRelativeTime(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
const now = new Date();
const diffMs = date - now;
const diffMins = Math.floor(diffMs / 60000);
const diffSecs = Math.floor((diffMs % 60000) / 1000);
if (diffMs < 0) {
return Math.abs(diffMins) + ' minute(s) ago';
} else if (diffMins > 0) {
return 'in ' + diffMins + ' minute(s)';
} else {
return 'in ' + diffSecs + ' second(s)';
}
}
export function getMinutesAgo(dateString) {
if (!dateString) return null;
// Handle both string and Date object inputs
let date;
if (typeof dateString === 'string') {
date = new Date(dateString);
} else if (dateString instanceof Date) {
date = dateString;
} else {
console.warn('getMinutesAgo: Expected string or Date, got:', typeof dateString, dateString);
return null;
}
// Check if date is valid
if (isNaN(date.getTime())) {
console.warn('getMinutesAgo: Invalid date:', dateString, 'parsed as:', date);
return null;
}
// Calculate difference in milliseconds
// Date.now() is UTC-based, date.getTime() is UTC-based, so this should be correct
const now = Date.now();
const dateTime = date.getTime();
const diffMs = now - dateTime;
// Convert to minutes and round
const minutes = Math.round(diffMs / 60000);
// If negative (future date), return null
if (minutes < 0) {
console.warn('getMinutesAgo: Future date detected:', dateString, 'parsed as:', date.toISOString());
return null;
}
// Safeguard against unreasonably large values (likely parsing errors)
// But don't cap at 60 - radar frames can legitimately be older in some cases
if (minutes > 100000) { // e.g., more than ~69 days ago
console.warn('getMinutesAgo: Unreasonably large minutes value:', minutes, 'for date:', dateString, 'parsed as:', date.toISOString());
return null;
}
return minutes;
}
// Format frame time for display (consolidates duplicate logic)
export function formatFrameTime(frame, isExtendedMode) {
if (!frame.absoluteObservationTime) return '-';
if (isExtendedMode) {
return formatDate(frame.absoluteObservationTime);
} else {
const minutesAgo = getMinutesAgo(frame.absoluteObservationTime);
// If minutesAgo is null (invalid date or edge case), fall back to formatted date
if (minutesAgo === null) {
return formatDate(frame.absoluteObservationTime);
}
return `${minutesAgo} min ago`;
}
}
// Network error detection (consolidates duplicate checks)
export function isNetworkError(error) {
return error.name === 'TypeError' ||
error.name === 'AbortError' ||
error.message.includes('fetch') ||
error.message.includes('network') ||
error.message.includes('Failed to fetch') ||
error.message === 'Network error';
}
export function isNetworkErrorResponse(response) {
return response.status === 0 || response.status >= 500;
}
// DOM helpers
export function getElement(id) {
return document.getElementById(id);
}
export function setElementText(id, text) {
const el = getElement(id);
if (el) el.textContent = text;
}
export function setElementHTML(id, html) {
const el = getElement(id);
if (el) el.innerHTML = html;
}
// Create cache status data object (consolidates duplicate creation)
export function createCacheStatusData(errorData) {
const details = errorData.details || errorData;
const isUpdating = details.updateTriggered === true ||
(errorData.message && errorData.message.includes('in progress')) ||
(details.statusMessage && details.statusMessage.includes('in progress'));
return {
frames: [],
isUpdating: isUpdating,
cacheIsValid: details.cacheIsValid || false,
cacheExpiresAt: details.cacheExpiresAt || null,
nextUpdateTime: details.nextUpdateTime || null
};
}
// Trigger background cache refresh (consolidates duplicate calls)
export function triggerBackgroundRefresh(refreshEndpoint) {
if (refreshEndpoint) {
fetch(refreshEndpoint, {
method: 'POST',
signal: AbortSignal.timeout(5000)
}).catch(err => {
console.debug('Background cache refresh trigger failed (non-critical):', err);
});
}
}
// Preload all frame images to prevent jiggle
export function preloadImages(frames) {
frames.forEach(frame => {
const img = new Image();
img.src = frame.imageUrl;
});
}