diff --git a/.gitignore b/.gitignore index 60d02e5..af0a850 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,14 @@ jest-temp/ # Cache .cache/ +# --- Java 17 / Maven --- +target/ +*.class +*.jar +*.war +*.ear +hs_err_pid* + # --- Python / Jupyter --- __pycache__/ *.pyc diff --git a/README.md b/README.md index 03d9545..c09a984 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,28 @@ Building civic tools for low-resource communities requires more than technical s Java model classes aligned with a defined schema Startup validation logging + # Building the Backend + + The backend targets **Java 17**. The build uses a Maven toolchain to compile + and test on JDK 17 regardless of the JVM Maven itself runs under (e.g. + Homebrew's Maven bundles a newer JDK). This requires a one-time, machine-local + `~/.m2/toolchains.xml` registering a JDK 17 install, for example: + + ```xml + + + jdk + 17 + + /path/to/your/jdk-17 + + + + ``` + + Find your JDK 17 path with `/usr/libexec/java_home -v 17` (macOS). Without a + matching toolchain entry, the build fails with "No toolchain found". + # Frontend Lightweight static frontend Mobile-first interface diff --git a/backend/backend/src/main/resources/application.properties b/backend/backend/src/main/resources/application.properties deleted file mode 100644 index e69de29..0000000 diff --git a/backend/pom.xml b/backend/pom.xml index 449520e..7136442 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -66,6 +66,28 @@ ${maven.multiModuleProjectDirectory} + + + + org.apache.maven.plugins + maven-toolchains-plugin + 3.1.0 + + + + 17 + + + + + + + toolchain + + + + diff --git a/backend/service/DecisionAgentService.java b/backend/service/DecisionAgentService.java deleted file mode 100644 index e69de29..0000000 diff --git a/backend/src/main/java/org/firststep/backend/controller/ResourceController.java b/backend/src/main/java/org/firststep/backend/controller/ResourceController.java index 2255867..60fc4f4 100644 --- a/backend/src/main/java/org/firststep/backend/controller/ResourceController.java +++ b/backend/src/main/java/org/firststep/backend/controller/ResourceController.java @@ -8,6 +8,7 @@ import org.firststep.backend.model.Resource; import org.firststep.backend.service.ResourceService; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -20,6 +21,9 @@ public class ResourceController { private final ResourceService service; + @Value("${app.seasonal.images.dir:backend/src/main/resources/static/images/seasonal}") + private String seasonalImagesDir; + public ResourceController(ResourceService service) { this.service = service; } @@ -43,7 +47,7 @@ public ResponseEntity getById(@PathVariable String id) { @GetMapping("/seasonal-images") public ResponseEntity> getSeasonalImages() { - File dir = new File("backend/src/main/resources/static/images/seasonal"); + File dir = new File(seasonalImagesDir); if (!dir.exists() || !dir.isDirectory()) { return ResponseEntity.ok(Collections.emptyList()); } diff --git a/backend/src/main/java/org/firststep/backend/service/DecisionAgentService.java b/backend/src/main/java/org/firststep/backend/service/DecisionAgentService.java index b7b09af..bdd3d2c 100644 --- a/backend/src/main/java/org/firststep/backend/service/DecisionAgentService.java +++ b/backend/src/main/java/org/firststep/backend/service/DecisionAgentService.java @@ -2,15 +2,11 @@ import java.util.ArrayList; import java.util.Comparator; -import java.util.HashSet; import java.util.List; import java.util.Locale; -import java.util.Set; -import org.firststep.backend.dto.Citation; import org.firststep.backend.dto.DecisionRequest; import org.firststep.backend.dto.DecisionResponse; -import org.firststep.backend.dto.DecisionStep; import org.firststep.backend.model.NewsItem; import org.firststep.backend.model.Resource; import org.springframework.stereotype.Service; @@ -62,8 +58,6 @@ public DecisionResponse decide(DecisionRequest request) { return resp; } - - List resources = resourceService.getAllResources(); List news = newsService.getAllNews(); @@ -126,7 +120,7 @@ private List selectTopResources(List all, String q, boolean scored.sort(Comparator.comparingInt(ResourceScore::score).reversed()); - int limit = 10; + int limit = 5; return scored.stream().limit(limit).map(ResourceScore::resource).toList(); } @@ -156,7 +150,7 @@ private List selectTopNews(List all, String q, List scored.sort(Comparator.comparingInt(NewsScore::score).reversed()); - int limit = 6; + int limit = 3; return scored.stream().limit(limit).map(NewsScore::news).toList(); } @@ -188,33 +182,6 @@ private String safeLower(String s) { return s == null ? "" : s.toLowerCase(Locale.ROOT).trim(); } - private String formatFirstLocation(Resource.Location loc) { - if (loc == null) return null; - if (Boolean.TRUE.equals(loc.confidential)) return null; - - String address = loc.address; - String city = loc.city; - String state = loc.state; - String zip = loc.zip; - - StringBuilder sb = new StringBuilder(); - if (address != null && !address.isBlank()) sb.append(address); - - if (city != null && !city.isBlank()) { - if (sb.length() > 0) sb.append(", "); - sb.append(city); - } - if (state != null && !state.isBlank()) { - if (sb.length() > 0) sb.append(", "); - sb.append(state); - } - if (zip != null && !zip.isBlank()) { - sb.append(" ").append(zip); - } - - return sb.length() == 0 ? null : sb.toString().trim(); - } - private String buildPrompt( String q, boolean urgent, @@ -256,15 +223,7 @@ private String mapperToTrimmedResourcesJson(List resources) { m.put("category", r.category); m.put("urgency", r.urgency); m.put("summary", r.summary); - m.put("description", r.description); - m.put("eligibility", r.eligibility); - m.put("eligibilityAgeMin", r.eligibilityAgeMin); - m.put("eligibilityAgeMax", r.eligibilityAgeMax); - m.put("eligibilityGender", r.eligibilityGender); - m.put("tags", r.tags); - m.put("location", (r.locations != null && !r.locations.isEmpty()) ? formatFirstLocation(r.locations.get(0)) : null); m.put("phone", (r.phones != null && !r.phones.isEmpty()) ? r.phones.get(0).number : null); - m.put("website", (r.websites != null && !r.websites.isEmpty()) ? r.websites.get(0).url : null); return m; }).toList(); diff --git a/backend/src/main/java/org/firststep/backend/service/NewsService.java b/backend/src/main/java/org/firststep/backend/service/NewsService.java index 8364542..aba8b80 100644 --- a/backend/src/main/java/org/firststep/backend/service/NewsService.java +++ b/backend/src/main/java/org/firststep/backend/service/NewsService.java @@ -1,7 +1,6 @@ package org.firststep.backend.service; import java.nio.file.Path; -import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -19,14 +18,8 @@ public class NewsService implements DecisionAgentService.NewsServiceLike { private final ObjectMapper mapper = new ObjectMapper(); - private final RssFeedSource rssFeedService; - private List staticItems = Collections.emptyList(); - public NewsService(RssFeedSource rssFeedService) { - this.rssFeedService = rssFeedService; - } - @EventListener(ApplicationReadyEvent.class) public void init() { try { @@ -47,10 +40,7 @@ public List getAllNews() { } public List getAll() { - List merged = new ArrayList<>(); - merged.addAll(rssFeedService.getRssItems()); - merged.addAll(staticItems); - return Collections.unmodifiableList(merged); + return Collections.unmodifiableList(staticItems); } -} \ No newline at end of file +} diff --git a/backend/src/main/java/org/firststep/backend/service/OllamaService.java b/backend/src/main/java/org/firststep/backend/service/OllamaService.java index 092cd23..28dcba5 100644 --- a/backend/src/main/java/org/firststep/backend/service/OllamaService.java +++ b/backend/src/main/java/org/firststep/backend/service/OllamaService.java @@ -43,7 +43,8 @@ public String generate(String prompt, double temperature) throws IOException, In "model", model, "prompt", prompt, "stream", false, - "temperature", temperature + "temperature", temperature, + "options", Map.of("num_predict", 1000) ); String json = mapper.writeValueAsString(body); diff --git a/backend/src/main/java/org/firststep/backend/service/ResourceService.java b/backend/src/main/java/org/firststep/backend/service/ResourceService.java index f54a63d..6be422d 100644 --- a/backend/src/main/java/org/firststep/backend/service/ResourceService.java +++ b/backend/src/main/java/org/firststep/backend/service/ResourceService.java @@ -7,6 +7,7 @@ import java.nio.file.Path; import org.firststep.backend.model.Resource; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; @@ -23,6 +24,9 @@ public class ResourceService implements DecisionAgentService.ResourceServiceLike private final ObjectMapper mapper = new ObjectMapper(); private List resources = Collections.emptyList(); + @Value("${app.data.dir:app/data}") + private String dataDir; + /** * Loads resources after the Spring application is ready. * Tries external canonical file app/data/resources.json first, @@ -31,16 +35,16 @@ public class ResourceService implements DecisionAgentService.ResourceServiceLike @EventListener(ApplicationReadyEvent.class) public void init() { // Try external canonical file first + Path external = Path.of(dataDir, "resources.json"); try { - Path external = Path.of("app", "data", "resources.json"); if (external.toFile().exists()) { JsonNode root = mapper.readTree(external.toFile()); resources = parseJsonNodeToList(root); - System.out.println("Loaded resources from app/data/resources.json (" + resources.size() + " records)"); + System.out.println("Loaded resources from " + external + " (" + resources.size() + " records)"); return; } } catch (Exception e) { - System.err.println("Failed to load app/data/resources.json: " + e.getMessage()); + System.err.println("Failed to load " + external + ": " + e.getMessage()); } // Fallback: classpath resources.json diff --git a/backend/src/main/java/org/firststep/backend/service/RssFeedService.java b/backend/src/main/java/org/firststep/backend/service/RssFeedService.java index 070ddfe..30b8705 100644 --- a/backend/src/main/java/org/firststep/backend/service/RssFeedService.java +++ b/backend/src/main/java/org/firststep/backend/service/RssFeedService.java @@ -14,6 +14,7 @@ import java.net.URLConnection; import java.text.SimpleDateFormat; import java.util.*; +import java.util.stream.Collectors; @Service public class RssFeedService implements RssFeedSource { @@ -29,7 +30,7 @@ public class RssFeedService implements RssFeedSource { @Scheduled( fixedDelayString = "${news.rss.refresh-interval:3600000}", - initialDelay = 5000 + initialDelayString = "${news.rss.initial-delay:500}" ) public void fetchFeeds() { if (rssFeedUrls == null || rssFeedUrls.isBlank()) { @@ -81,6 +82,7 @@ private SyndFeed loadFeed(String url) { conn.setReadTimeout(8000); SyndFeedInput input = new SyndFeedInput(); + input.setAllowDoctypes(true); return input.build(new XmlReader(conn)); } catch (Exception e) { @@ -109,14 +111,159 @@ private NewsItem convertEntry(SyndFeed feed, SyndEntry entry) { item.published = DATE_FMT.format(published); item.active = true; - item.type = "general-news"; + item.type = "legislation"; item.urgency = "standard"; - item.categoryTags = List.of("Community", "Updates"); - item.whyItMatters = "Stay informed about news and updates in your community."; + + String text = (item.headline + " " + item.summary).toLowerCase(); + Classification cls = classifyLegislation(text); + item.categoryTags = cls.categoryTags; + item.resourceTags = cls.resourceTags; + item.whyItMatters = cls.whyItMatters; + + String relatingTo = extractRelatingTo(item.summary); + if (relatingTo != null) { + item.headline = relatingTo; + item.whyItMatters = relatingTo; + } return item; } + private static final int RELATING_TO_MAX_CHARS = 120; + + private static String extractRelatingTo(String summary) { + if (summary == null) return null; + String upper = summary.toUpperCase(); + int start = upper.indexOf("RELATING TO"); + if (start < 0) return null; + + int end = Integer.MAX_VALUE; + + // Rule 1: first period after "RELATING TO" (include the period) + int periodIdx = summary.indexOf('.', start); + if (periodIdx >= 0) end = Math.min(end, periodIdx + 1); + + // Rule 2: "This " boundary — stop before it + // Delaware descriptions always follow the act title with "This Act…", "This Senate…", etc. + java.util.regex.Matcher m = java.util.regex.Pattern + .compile("\\bThis\\s+[A-Z]", java.util.regex.Pattern.CASE_INSENSITIVE) + .matcher(summary); + while (m.find()) { + if (m.start() > start) { + int boundary = m.start(); + while (boundary > start && Character.isWhitespace(summary.charAt(boundary - 1))) { + boundary--; + } + end = Math.min(end, boundary); + break; + } + } + + if (end == Integer.MAX_VALUE || end <= start) return null; + + String raw = summary.substring(start, end).trim(); + + // Rule 3: character cap — truncate at last word boundary + if (raw.length() > RELATING_TO_MAX_CHARS) { + raw = raw.substring(0, RELATING_TO_MAX_CHARS); + int lastSpace = raw.lastIndexOf(' '); + if (lastSpace > 0) raw = raw.substring(0, lastSpace); + } + + if (raw.isEmpty()) return null; + + if (!raw.endsWith(".")) raw = raw + "."; + + return Character.toUpperCase(raw.charAt(0)) + raw.substring(1).toLowerCase(); + } + + private static final class Classification { + List categoryTags; + List resourceTags; + String whyItMatters; + Classification(List categoryTags, List resourceTags, String whyItMatters) { + this.categoryTags = categoryTags; + this.resourceTags = resourceTags; + this.whyItMatters = whyItMatters; + } + } + + private static final Map TAG_KEYWORDS = new LinkedHashMap<>(); + static { + TAG_KEYWORDS.put("housing", new String[]{"housing", "rent", "landlord", "tenant", "evict", + "mortgage", "residential", "manufactured home", + "affordable rental", "shelter"}); + TAG_KEYWORDS.put("healthcare", new String[]{"health", "medical", "medicaid", "medicare", + "hospital", "clinic", "mental health", "prescription", + "nursing", "patient", "wellness", "behavioral health", + "opioid", "drug", "therapy", "physician", "care", + "insurance", "vaccination", "public health", + "drinking water", "long-term care", "school-based health"}); + TAG_KEYWORDS.put("food", new String[]{"food", "nutrition", "snap", "hunger", "grocery", + "meal", "wic", "restaurant meals", "dietitian", + "farm", "agriculture"}); + TAG_KEYWORDS.put("employment", new String[]{"employ", "worker", "wage", "labor", "job", + "workplace", "paid leave", "unemployment", + "workforce", "occupational", "salary", "licensure"}); + TAG_KEYWORDS.put("utilities", new String[]{"utility", "utilities", "electric", "energy", + "net meter", "solar", "water system"}); + TAG_KEYWORDS.put("disability", new String[]{"disability", "disabilities", "accessible", "accessibility", + "accommodation", "developmental disability", + "rehabilitation", "hearing", "blue envelope"}); + TAG_KEYWORDS.put("benefits", new String[]{"benefit", "assistance", "subsidy", "aid", + "social service", "low-income", "poverty", + "state employee benefit", "child care", + "school-based", "voucher"}); + TAG_KEYWORDS.put("legal", new String[]{"court", "justice", "civil right", "equal accommodation", + "protection", "eviction", "trafficking", + "stalking", "criminal", "juvenile"}); + } + + private static final Map TAG_WHY = new LinkedHashMap<>(); + static { + TAG_WHY.put("housing", "This new law may affect your rights as a renter, homeowner, or manufactured-home resident in Delaware."); + TAG_WHY.put("healthcare", "This new law may change what health services or coverage are available to you or your family."); + TAG_WHY.put("food", "This new law may affect food assistance programs or nutrition services in your community."); + TAG_WHY.put("employment", "This new law may change your rights or benefits at work, including wages, leave, or licensing."); + TAG_WHY.put("utilities", "This new law may affect your electric, water, or energy bills."); + TAG_WHY.put("disability", "This new law may expand services or protections for people with disabilities."); + TAG_WHY.put("benefits", "This new law may change assistance programs or benefits available to low-income Delawareans."); + TAG_WHY.put("legal", "This new law may affect your legal rights or access to the courts."); + } + + private static Classification classifyLegislation(String text) { + List matched = new ArrayList<>(); + for (Map.Entry entry : TAG_KEYWORDS.entrySet()) { + for (String kw : entry.getValue()) { + if (text.contains(kw)) { + matched.add(entry.getKey()); + break; + } + } + } + + if (matched.isEmpty()) { + return new Classification( + List.of("Delaware Legislation"), + List.of(), + "Stay informed about new laws signed by the Governor of Delaware." + ); + } + + // Display tags: title-cased category names + List categoryTags = matched.stream() + .map(t -> Character.toUpperCase(t.charAt(0)) + t.substring(1)) + .collect(Collectors.toList()); + + // Resource tags: used for search filtering in the app + List resourceTags = new ArrayList<>(matched); + + // Use the why-it-matters for the highest-priority matched tag + String why = TAG_WHY.get(matched.get(0)); + + return new Classification(categoryTags, resourceTags, why); + } + private String extractSummary(SyndEntry entry) { // 1. Standard if (entry.getDescription() != null && entry.getDescription().getValue() != null) { diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 77479df..6d56f26 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -1,6 +1,11 @@ server.port=8080 spring.jackson.property-naming-strategy=LOWER_CAMEL_CASE +# Filesystem locations (relative to the working directory by default). +# Override these when running from a different directory, e.g. in a container. +app.data.dir=app/data +app.seasonal.images.dir=backend/src/main/resources/static/images/seasonal + ollama.api.url=http://localhost:11434/api/generate ollama.model=gemma2:2b @@ -8,9 +13,11 @@ ollama.model=gemma2:2b ai.enabled=true # RSS feeds for Weekly Updates (comma-separated) -news.rss.urls=https://winn22.org/feed,https://legis.delaware.gov/RSSFeed +news.rss.urls=https://legis.delaware.gov/rss/RssFeeds/GovernorSignedLegislation # Refresh interval in milliseconds (3600000 = 1 hour) news.rss.refresh-interval=3600000 +# Initial delay before first fetch in milliseconds (500 = 0.5 seconds) +news.rss.initial-delay=500 diff --git a/backend/src/main/resources/static/app.js b/backend/src/main/resources/static/app.js index 0383c93..3796bda 100644 --- a/backend/src/main/resources/static/app.js +++ b/backend/src/main/resources/static/app.js @@ -20,7 +20,7 @@ const STRINGS = { weeklyUpdatesSub: "News and Changes Impacting You", weeklyUpdatesDesc: "Stay up to date on the rules, public meetings and changes that affect housing, benefits and community services. Read the highlights and learn about important deadlines, new requirements and policy updates so you can participate and plan ahead.", aiTitle: "AI Guidance", - aiSubtitle: "Ask a question in natural language to find resources", + aiSubtitle: "Tell me what you need help with!", aiPlaceholder: "E.g., I need rental help near Wilmington for seniors", aiButton: "Get Help", aiUrgent: "🚨 Urgent", @@ -204,6 +204,7 @@ window.addEventListener("DOMContentLoaded", () => { const savedFontSize = localStorage.getItem("font-size"); if (savedFontSize) document.documentElement.style.fontSize = savedFontSize; loadSidebarNews(); + loadSidebarLaws(); applyLanguage(); }); @@ -213,7 +214,8 @@ const resultsScreen = document.getElementById("results-screen"); const filterScreen = document.getElementById("filter-screen"); const urgentFilterButton = document.getElementById("urgent-filter"); const continueButton = document.getElementById("continue-button"); -const newsResultsContainer = document.getElementById("news-results"); +const newsOuterContainer = document.getElementById("news-results"); +const newsResultsContainer = document.getElementById("news-results-main"); const seasonalResultsContainer = document.getElementById("seasonal-results"); const essentialsResultsContainer = document.getElementById("essentials-results"); const homeScreen = document.getElementById("home-screen"); @@ -223,6 +225,8 @@ const backResultsButton = document.getElementById("back-results-button"); const backHomeButton = document.getElementById("back-home-button"); let urgentFilterSelected = false; +let allNewsItems = []; +let activeNewsTag = null; // ===== Category Navigation ===== document.getElementById("housing-help-button").addEventListener("click", () => { @@ -379,7 +383,7 @@ function renderDecisionResponse(data) { // ===== Resource Loading ===== async function loadHousingResources() { - newsResultsContainer.style.display = "none"; + newsOuterContainer.style.display = "none"; essentialsResultsContainer.style.display = "none"; seasonalResultsContainer.style.display = "none"; detailScreen.style.display = "none"; @@ -412,7 +416,7 @@ async function loadHousingResources() { async function loadEssentialsResources() { resultsContainer.style.display = "none"; - newsResultsContainer.style.display = "none"; + newsOuterContainer.style.display = "none"; seasonalResultsContainer.style.display = "none"; detailScreen.style.display = "none"; essentialsResultsContainer.style.display = "block"; @@ -435,13 +439,17 @@ async function loadNewsUpdates() { essentialsResultsContainer.style.display = "none"; seasonalResultsContainer.style.display = "none"; detailScreen.style.display = "none"; - newsResultsContainer.style.display = "block"; + newsOuterContainer.style.display = "grid"; newsResultsContainer.innerHTML = `

${t("loading")}

`; + renderLawsColumn(); + try { const response = await fetch("/api/news"); - const newsItems = await response.json(); - displayNews(newsItems); + allNewsItems = await response.json(); + activeNewsTag = null; + renderNewsFilter(allNewsItems); + renderNewsItems(); showResultsScreen(); } catch (error) { console.error(error); @@ -450,6 +458,105 @@ async function loadNewsUpdates() { return Promise.resolve(); } +async function renderLawsColumn() { + const col = document.getElementById("news-results-laws"); + col.innerHTML = `
+

Delaware's Newest Laws

+

${t("loading")}

+
`; + try { + const response = await fetch("/api/news/rss"); + const items = await response.json(); + const list = document.getElementById("laws-list"); + list.innerHTML = ""; + items.slice(0, 35).forEach(item => { + const card = document.createElement("div"); + card.className = "news-item"; + card.innerHTML = ` +

${item.why_it_matters || item.headline}

+
${item.published || "Latest"}
+ `; + card.addEventListener("click", () => showNewsDetail(item)); + list.appendChild(card); + }); + } catch (err) { + const list = document.getElementById("laws-list"); + if (list) list.innerHTML = + '

Unable to load laws

'; + } +} + +function renderNewsFilter(items) { + const existingFilter = document.getElementById("news-tag-filter"); + if (existingFilter) existingFilter.remove(); + + const uniqueTags = [...new Set(items.flatMap(i => i.category_tags || []))].sort(); + if (uniqueTags.length === 0) return; + + const bar = document.createElement("div"); + bar.className = "filter-options"; + bar.id = "news-tag-filter"; + + const allChip = document.createElement("button"); + allChip.className = "filter-chip selected"; + allChip.textContent = "All"; + allChip.addEventListener("click", () => { + activeNewsTag = null; + bar.querySelectorAll(".filter-chip").forEach(c => c.classList.remove("selected")); + allChip.classList.add("selected"); + renderNewsItems(); + }); + bar.appendChild(allChip); + + uniqueTags.forEach(tag => { + const chip = document.createElement("button"); + chip.className = "filter-chip"; + chip.textContent = tag; + chip.addEventListener("click", () => { + activeNewsTag = tag; + bar.querySelectorAll(".filter-chip").forEach(c => c.classList.remove("selected")); + chip.classList.add("selected"); + renderNewsItems(); + }); + bar.appendChild(chip); + }); + + newsResultsContainer.prepend(bar); +} + +function renderNewsItems() { + const filtered = activeNewsTag + ? allNewsItems.filter(i => (i.category_tags || []).includes(activeNewsTag)) + : allNewsItems; + + const filterBar = document.getElementById("news-tag-filter"); + + newsResultsContainer.innerHTML = renderPageHeader(t("weeklyUpdates"), t("weeklyUpdatesDesc")); + + if (filterBar) newsResultsContainer.prepend(filterBar); + + filtered.forEach(item => { + const cats = (item.category_tags || []).join(" · "); + const sourceLink = item.source_url + ? ` + ${item.source_name} ↗ + ` + : item.source_name; + const card = document.createElement("div"); + card.className = "resource-card"; + card.innerHTML = ` + ${cats || "General"} +

${item.headline}

+

${item.summary}

+

Why this matters: ${item.why_it_matters}

+

${sourceLink} · ${item.published}

+

${t("viewDetails")}

+ `; + card.addEventListener("click", () => showNewsDetail(item)); + newsResultsContainer.appendChild(card); + }); +} + async function loadSidebarNews() { try { const response = await fetch("/api/news"); @@ -476,6 +583,32 @@ async function loadSidebarNews() { } } +async function loadSidebarLaws() { + try { + const response = await fetch("/api/news/rss"); + const items = await response.json(); + const container = document.getElementById("sidebar-laws"); + container.innerHTML = ""; + + items.slice(0, 3).forEach(item => { + const card = document.createElement("div"); + card.className = "news-item"; + card.innerHTML = ` +

${item.why_it_matters || item.headline}

+
${item.published || "Latest"}
+ `; + card.addEventListener("click", () => { + loadNewsUpdates().then(() => showNewsDetail(item)); + }); + container.appendChild(card); + }); + } catch (error) { + console.error("Laws sidebar failed to load:", error); + document.getElementById("sidebar-laws").innerHTML = + '

Unable to load updates

'; + } +} + // ===== Screen Management ===== let activeResultsContainer = null; @@ -489,12 +622,16 @@ function showResultsScreen() { function showDetailScreen() { // Hide whichever results container is currently visible - [resultsContainer, newsResultsContainer, essentialsResultsContainer, seasonalResultsContainer].forEach(el => { + [resultsContainer, essentialsResultsContainer, seasonalResultsContainer].forEach(el => { if (el.style.display !== "none") { activeResultsContainer = el; el.style.display = "none"; } }); + if (newsOuterContainer.style.display !== "none") { + activeResultsContainer = newsOuterContainer; + newsOuterContainer.style.display = "none"; + } detailScreen.style.display = "block"; window.scrollTo(0, 0); } @@ -502,7 +639,8 @@ function showDetailScreen() { function hideDetailScreen() { detailScreen.style.display = "none"; if (activeResultsContainer) { - activeResultsContainer.style.display = "block"; + activeResultsContainer.style.display = + activeResultsContainer === newsOuterContainer ? "grid" : "block"; } else { resultsContainer.style.display = "block"; } @@ -606,28 +744,10 @@ function displayEssentials(resources) { } function displayNews(newsItems) { - newsResultsContainer.innerHTML = renderPageHeader(t("weeklyUpdates"), t("weeklyUpdatesDesc")); - - newsItems.forEach(item => { - const cats = (item.category_tags || []).join(" · "); - const sourceLink = item.source_url - ? ` - ${item.source_name} ↗ - ` - : item.source_name; - const card = document.createElement("div"); - card.className = "resource-card"; - card.innerHTML = ` - ${cats || "General"} -

${item.headline}

-

${item.summary}

-

Why this matters: ${item.why_it_matters}

-

${sourceLink} · ${item.published}

-

${t("viewDetails")}

- `; - card.addEventListener("click", () => showNewsDetail(item)); - newsResultsContainer.appendChild(card); - }); + allNewsItems = newsItems; + activeNewsTag = null; + renderNewsFilter(newsItems); + renderNewsItems(); } function showNewsDetail(item) { @@ -672,7 +792,7 @@ function showNewsDetail(item) { async function showSeasonalResources() { resultsContainer.style.display = "none"; - newsResultsContainer.style.display = "none"; + newsOuterContainer.style.display = "none"; essentialsResultsContainer.style.display = "none"; detailScreen.style.display = "none"; diff --git a/backend/src/main/resources/static/images/First step logo feet.png b/backend/src/main/resources/static/images/First step logo feet.png new file mode 100644 index 0000000..6074395 Binary files /dev/null and b/backend/src/main/resources/static/images/First step logo feet.png differ diff --git a/backend/src/main/resources/static/index.html b/backend/src/main/resources/static/index.html index 14b36e3..4e4828d 100644 --- a/backend/src/main/resources/static/index.html +++ b/backend/src/main/resources/static/index.html @@ -13,8 +13,11 @@
@@ -114,6 +117,10 @@

AI Guidance

Latest Updates

+
+

Delaware's Newest Laws

+ +
@@ -145,7 +152,10 @@

Refine Your Search

-
+
+
+ +

Community Resources

diff --git a/backend/src/main/resources/static/styles.css b/backend/src/main/resources/static/styles.css index 48b0f4d..c6b88e6 100644 --- a/backend/src/main/resources/static/styles.css +++ b/backend/src/main/resources/static/styles.css @@ -6,8 +6,8 @@ --success-color: #2d7d4a; --warning-color: #d97706; --error-color: #b91c1c; - --bg-light: #f5f0eb; - --bg-lighter: #faf8f5; + --bg-light: #f7eadc; + --bg-lighter: #fbecd6; --border-color: #e5ddd5; --text-primary: #1c1917; --text-secondary: #6b7280; @@ -29,7 +29,7 @@ body { margin: 0; padding: 0; line-height: 1.6; - font-size: 1rem; + font-size: 1.125rem; } /* ===== Header ===== */ @@ -64,9 +64,11 @@ body { cursor: pointer; } +.logo-icon { flex-shrink: 0; } + .logo { margin: 0; - font-size: 22px; + font-size: 24px; font-weight: 700; color: var(--primary-color); line-height: 1.2; @@ -74,7 +76,7 @@ body { .logo-tagline { margin: 0; - font-size: 12px; + font-size: 14px; color: var(--text-secondary); font-weight: 400; } @@ -84,9 +86,9 @@ body { align-items: center; gap: 8px; background: linear-gradient(135deg, #fef3e8 0%, #fff8f0 100%); - padding: 8px 16px; + padding: 9px 18px; border-radius: 8px; - font-size: 13px; + font-size: 15px; color: var(--primary-color); font-weight: 500; white-space: nowrap; @@ -101,7 +103,7 @@ body { box-shadow: var(--shadow-sm); } -.ai-icon { font-size: 16px; } +.ai-icon { font-size: 18px; } .header-utilities { flex: 0 0 auto; @@ -111,16 +113,16 @@ body { } .utility-button { - padding: 7px 11px; + padding: 8px 13px; border: 1px solid var(--border-color); background: white; border-radius: 6px; cursor: pointer; - font-size: 13px; + font-size: 15px; font-weight: 500; color: var(--text-primary); transition: var(--transition); - min-width: 34px; + min-width: 38px; text-align: center; } @@ -146,11 +148,11 @@ body { .nav-link { color: rgba(255,255,255,0.85); text-decoration: none; - padding: 11px 20px; + padding: 12px 22px; display: flex; align-items: center; font-weight: 500; - font-size: 14px; + font-size: 16px; border-bottom: 3px solid transparent; transition: var(--transition); cursor: pointer; @@ -178,14 +180,14 @@ body { .hero-title { margin: 0 0 10px 0; - font-size: 30px; + font-size: 32px; font-weight: 700; line-height: 1.3; } .hero-subtitle { margin: 0; - font-size: 17px; + font-size: 19px; opacity: 0.92; line-height: 1.5; } @@ -199,27 +201,27 @@ body { } .home-main { min-width: 0; } -.home-side { min-width: 0; } +.home-side { min-width: 0; position: sticky; top: 160px; align-self: start; } /* ===== Category Cards ===== */ .category-list { display: flex; flex-direction: column; - gap: 10px; + gap: 14px; } .category-card { display: flex; align-items: center; gap: 16px; - padding: 18px 20px; + padding: 26px 24px; background: white; border: 1px solid var(--border-color); border-left: 4px solid var(--accent-color); border-radius: var(--radius); cursor: pointer; transition: var(--transition); - font-size: 15px; + font-size: 17px; text-align: left; width: 100%; } @@ -232,25 +234,25 @@ body { .category-card:active { transform: translateY(0); } -.category-icon { font-size: 26px; flex-shrink: 0; width: 36px; text-align: center; } +.category-icon { font-size: 34px; flex-shrink: 0; width: 44px; text-align: center; } .category-card h3 { margin: 0 0 4px 0; - font-size: 15px; + font-size: 19px; font-weight: 600; color: var(--text-primary); } .category-card p { margin: 0; - font-size: 13px; + font-size: 16px; color: var(--text-secondary); line-height: 1.4; } /* ===== AI Guidance Section ===== */ .ai-guidance-section { - background: linear-gradient(135deg, #fef3e8 0%, #fff8f0 100%); + background: linear-gradient(135deg, #0c5b2f 0%, #f88b0e 100%); border: 1px solid #f5c89a; border-radius: var(--radius); padding: 22px; @@ -259,25 +261,25 @@ body { .ai-guidance-section h3 { margin: 0 0 5px 0; - font-size: 17px; + font-size: 19px; font-weight: 600; - color: var(--primary-color); + color: white; } .ai-guidance-subtitle { margin: 0 0 14px 0; - font-size: 13px; - color: var(--text-secondary); + font-size: 15px; + color: white; } .ai-input-group { display: flex; gap: 8px; margin-bottom: 10px; } .ai-input { flex: 1; - padding: 10px 14px; + padding: 11px 16px; border: 1px solid #f5c89a; border-radius: 8px; - font-size: 14px; + font-size: 16px; background: white; transition: var(--transition); } @@ -289,23 +291,23 @@ body { } .ai-submit-btn { - padding: 10px 20px; - background: var(--accent-color); + padding: 11px 22px; + background: var(--primary-color); color: white; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; transition: var(--transition); - font-size: 14px; + font-size: 16px; white-space: nowrap; } -.ai-submit-btn:hover { background: #c4662a; } +.ai-submit-btn:hover { background: var(--primary-light); } .ai-submit-btn:active { background: var(--primary-color); } .ai-reset-btn { - padding: 10px 16px; + padding: 11px 18px; background: white; color: var(--text-secondary); border: 1px solid var(--border-color); @@ -313,7 +315,7 @@ body { font-weight: 500; cursor: pointer; transition: var(--transition); - font-size: 14px; + font-size: 16px; white-space: nowrap; } @@ -330,13 +332,13 @@ body { .detail-source-link { display: inline-block; margin-top: 8px; - color: white; + color: white !important; background: var(--accent-color); - padding: 7px 16px; + padding: 9px 18px; border-radius: 6px; text-decoration: none; font-weight: 600; - font-size: 14px; + font-size: 16px; transition: var(--transition); } @@ -345,12 +347,12 @@ body { .ai-chips { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; } .ai-chip { - padding: 5px 12px; + padding: 6px 14px; background: white; border: 1px solid #f5c89a; border-radius: 20px; cursor: pointer; - font-size: 13px; + font-size: 15px; transition: var(--transition); } @@ -367,9 +369,9 @@ body { align-items: center; gap: 12px; background: white; - padding: 14px 16px; + padding: 15px 18px; border-radius: 8px; - font-size: 14px; + font-size: 16px; color: var(--text-secondary); } @@ -389,9 +391,9 @@ body { .ai-error { color: var(--error-color); - font-size: 14px; + font-size: 16px; background: #fef2f2; - padding: 10px 14px; + padding: 12px 16px; border-radius: 8px; border: 1px solid #fecaca; margin: 0; @@ -414,11 +416,11 @@ body { border-bottom: 1px solid var(--border-color); } -.ai-response-icon { font-size: 20px; flex-shrink: 0; margin-top: 1px; } +.ai-response-icon { font-size: 22px; flex-shrink: 0; margin-top: 1px; } .ai-response-title { margin: 0; - font-size: 16px; + font-size: 18px; font-weight: 600; color: var(--primary-color); line-height: 1.3; @@ -426,7 +428,7 @@ body { .ai-response-notes { margin: 0 0 12px 0; - font-size: 13px; + font-size: 15px; color: var(--text-secondary); font-style: italic; } @@ -440,12 +442,12 @@ body { } .ai-step-number { - width: 24px; - height: 24px; + width: 26px; + height: 26px; border-radius: 50%; background: var(--accent-color); color: white; - font-size: 12px; + font-size: 14px; font-weight: 700; display: flex; align-items: center; @@ -457,21 +459,21 @@ body { .ai-step-body { flex: 1; } .ai-step-title { - font-size: 14px; + font-size: 16px; font-weight: 500; color: var(--text-primary); line-height: 1.4; } .ai-step-why { - font-size: 13px; + font-size: 15px; color: var(--text-secondary); margin-top: 3px; line-height: 1.4; } .ai-no-steps { - font-size: 14px; + font-size: 16px; color: var(--text-secondary); margin: 0; } @@ -480,7 +482,7 @@ body { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border-color); - font-size: 13px; + font-size: 15px; color: var(--text-secondary); } @@ -498,13 +500,11 @@ body { border-radius: var(--radius); padding: 18px; box-shadow: var(--shadow-sm); - position: sticky; - top: 160px; } .resource-panel h3 { margin: 0 0 14px 0; - font-size: 15px; + font-size: 17px; font-weight: 600; color: var(--text-primary); padding-bottom: 10px; @@ -526,13 +526,13 @@ body { .news-item h4 { margin: 0 0 3px 0; - font-size: 13px; + font-size: 15px; font-weight: 600; color: var(--text-primary); line-height: 1.3; } -.news-item .news-date { font-size: 11px; color: var(--text-light); } +.news-item .news-date { font-size: 13px; color: var(--text-light); } /* ===== Filter Screen ===== */ #filter-screen { @@ -550,15 +550,15 @@ body { margin-bottom: 16px; } -.screen-header h2 { margin: 0; font-size: 22px; font-weight: 700; } +.screen-header h2 { margin: 0; font-size: 24px; font-weight: 700; } .back-button { - padding: 7px 14px; + padding: 8px 16px; background: white; border: 1px solid var(--border-color); border-radius: 8px; cursor: pointer; - font-size: 14px; + font-size: 16px; transition: var(--transition); color: var(--text-primary); white-space: nowrap; @@ -566,17 +566,17 @@ body { .back-button:hover { background: var(--bg-light); border-color: var(--primary-color); color: var(--primary-color); } -.screen-subtitle { color: var(--text-secondary); font-size: 14px; margin: 0 0 18px 0; } +.screen-subtitle { color: var(--text-secondary); font-size: 16px; margin: 0 0 18px 0; } .filter-options { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 22px; } .filter-chip { - padding: 9px 16px; + padding: 10px 18px; background: white; border: 2px solid var(--border-color); border-radius: 24px; cursor: pointer; - font-size: 14px; + font-size: 16px; font-weight: 500; transition: var(--transition); color: var(--text-primary); @@ -586,14 +586,14 @@ body { .filter-chip.selected { background: var(--accent-color); color: white; border-color: var(--accent-color); } .primary-button { - padding: 11px 24px; + padding: 13px 26px; background: var(--primary-color); color: white; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; - font-size: 15px; + font-size: 17px; transition: var(--transition); } @@ -604,6 +604,20 @@ body { .results-list { display: flex; flex-direction: column; gap: 0; } +#news-results { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 24px; + align-items: start; +} + +.news-results-side { + min-width: 0; + position: sticky; + top: 160px; + align-self: start; +} + /* ===== Resource Cards ===== */ .resource-card { background: white; @@ -634,7 +648,7 @@ body { .card-title { margin: 0; - font-size: 15px; + font-size: 17px; font-weight: 600; color: var(--primary-color); line-height: 1.3; @@ -643,14 +657,14 @@ body { .card-summary { margin: 0 0 8px 0; - font-size: 13px; + font-size: 15px; color: var(--text-secondary); line-height: 1.5; } .card-phone { margin: 0 0 6px 0; - font-size: 13px; + font-size: 15px; } .card-phone a { @@ -663,20 +677,20 @@ body { .card-why { margin: 4px 0; - font-size: 13px; + font-size: 15px; color: var(--text-secondary); line-height: 1.4; } .card-source { margin: 6px 0 0 0; - font-size: 11px; + font-size: 13px; color: var(--text-light); } .card-cta { margin: 8px 0 0 0; - font-size: 12px; + font-size: 14px; font-weight: 600; color: var(--accent-color); letter-spacing: 0.2px; @@ -684,7 +698,7 @@ body { .empty-state { color: var(--text-secondary); - font-size: 14px; + font-size: 16px; padding: 20px; text-align: center; } @@ -692,9 +706,9 @@ body { /* ===== Urgency Tags ===== */ .urgency-tag { display: inline-block; - padding: 3px 10px; + padding: 4px 12px; border-radius: 12px; - font-size: 11px; + font-size: 13px; font-weight: 600; white-space: nowrap; flex-shrink: 0; @@ -713,14 +727,14 @@ body { .page-header h2 { margin: 0 0 6px 0; - font-size: 22px; + font-size: 24px; font-weight: 700; color: var(--primary-color); } .page-description { color: var(--text-secondary); - font-size: 14px; + font-size: 16px; line-height: 1.5; margin: 0; } @@ -728,7 +742,7 @@ body { /* ===== Category Group Headers ===== */ .category-group-header { margin: 22px 0 8px; - font-size: 14px; + font-size: 16px; font-weight: 600; color: var(--primary-color); text-transform: uppercase; @@ -752,7 +766,7 @@ body { .detail-org { margin: 0; - font-size: 22px; + font-size: 24px; font-weight: 700; color: var(--primary-color); flex: 1; @@ -767,7 +781,7 @@ body { .detail-section:last-child { border-bottom: none; } .detail-label { - font-size: 11px; + font-size: 13px; font-weight: 700; color: var(--text-light); text-transform: uppercase; @@ -776,7 +790,7 @@ body { } .detail-value { - font-size: 15px; + font-size: 17px; color: var(--text-primary); line-height: 1.5; } @@ -822,8 +836,8 @@ body { } .carousel-caption { - padding: 10px 14px; - font-size: 13px; + padding: 12px 16px; + font-size: 15px; color: var(--text-secondary); } @@ -841,7 +855,7 @@ body { } .essentials-nav-label { - font-size: 12px; + font-size: 14px; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; @@ -851,11 +865,11 @@ body { .essentials-nav-link { display: inline-block; - padding: 5px 12px; + padding: 6px 14px; background: white; border: 1px solid var(--border-color); border-radius: 20px; - font-size: 13px; + font-size: 15px; font-weight: 500; color: var(--primary-color); text-decoration: none; @@ -870,8 +884,8 @@ body { /* ===== Results Page AI Widget ===== */ .results-ai-section { - background: linear-gradient(135deg, #fef3e8 0%, #fff8f0 100%); - border: 1px solid #f5c89a; + background: var(--accent-color); + border: none; border-radius: var(--radius); padding: 20px 22px; margin-top: 28px; @@ -886,15 +900,15 @@ body { .results-ai-header h4 { margin: 0; - font-size: 15px; + font-size: 17px; font-weight: 600; - color: var(--primary-color); + color: white; } .results-ai-subtitle { margin: 0 0 12px 0; - font-size: 13px; - color: var(--text-secondary); + font-size: 15px; + color: white; } .results-ai-chips { @@ -905,12 +919,12 @@ body { } .results-ai-chip { - padding: 6px 14px; + padding: 7px 16px; background: white; border: 1px solid #f5c89a; border-radius: 20px; cursor: pointer; - font-size: 13px; + font-size: 15px; transition: var(--transition); color: var(--text-primary); } @@ -924,7 +938,7 @@ body { margin-bottom: 10px; } -.results-ai-input-row .ai-input { flex: 1; } +.results-ai-input-row .ai-input { flex: 1; border-color: rgba(255,255,255,0.6); } /* ===== High Contrast ===== */ body.high-contrast { background-color: #000; color: #fff; } @@ -962,17 +976,19 @@ body.high-contrast .card-cta { color: #ff0; } .header-content { flex-direction: column; gap: 10px; padding: 12px 16px; } .home-grid { grid-template-columns: 1fr; } .home-side { display: none; } - .hero-title { font-size: 24px; } - .hero-subtitle { font-size: 15px; } + #news-results { grid-template-columns: 1fr; } + .news-results-side { display: none; } + .hero-title { font-size: 26px; } + .hero-subtitle { font-size: 17px; } .hero-section { padding: 28px 20px; } .ai-banner-header { display: none; } .ai-input-group { flex-direction: column; } .container { padding: 16px; } .carousel-card { width: 88%; } .nav-content { overflow-x: auto; } - .nav-link { padding: 10px 14px; font-size: 13px; } + .nav-link { padding: 11px 16px; font-size: 15px; } .card-top { flex-direction: column; gap: 6px; } - .detail-org { font-size: 18px; } + .detail-org { font-size: 20px; } .main-nav { top: 52px; } } diff --git a/backend/src/test/java/org/firststep/backend/controller/NewsControllerTest.java b/backend/src/test/java/org/firststep/backend/controller/NewsControllerTest.java index debff6e..3d83a8b 100644 --- a/backend/src/test/java/org/firststep/backend/controller/NewsControllerTest.java +++ b/backend/src/test/java/org/firststep/backend/controller/NewsControllerTest.java @@ -49,8 +49,8 @@ RssFeedSource rssFeedSource() { } @Bean - NewsService newsService(RssFeedSource rssFeedSource) { - return new NewsService(rssFeedSource); + NewsService newsService() { + return new NewsService(); } } diff --git a/backend/src/test/java/org/firststep/backend/controller/ResourceControllerTest.java b/backend/src/test/java/org/firststep/backend/controller/ResourceControllerTest.java new file mode 100644 index 0000000..c6122d8 --- /dev/null +++ b/backend/src/test/java/org/firststep/backend/controller/ResourceControllerTest.java @@ -0,0 +1,38 @@ +package org.firststep.backend.controller; + +import org.firststep.backend.service.ResourceService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(ResourceController.class) +@ContextConfiguration(classes = {ResourceController.class, ResourceControllerTest.TestConfig.class}) +@TestPropertySource(properties = "app.seasonal.images.dir=src/test/resources/seasonal-test") +class ResourceControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Configuration + static class TestConfig { + @Bean + ResourceService resourceService() { + return new ResourceService(); + } + } + + @Test + void shouldReturnSeasonalImagesFromConfiguredDirectory() throws Exception { + mockMvc.perform(get("/api/seasonal-images")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0]").value("images/seasonal/Sample.png")); + } +} diff --git a/backend/target/classes/application.properties b/backend/target/classes/application.properties deleted file mode 100644 index 77479df..0000000 --- a/backend/target/classes/application.properties +++ /dev/null @@ -1,16 +0,0 @@ -server.port=8080 -spring.jackson.property-naming-strategy=LOWER_CAMEL_CASE - -ollama.api.url=http://localhost:11434/api/generate -ollama.model=gemma2:2b - -# Feature flag to prevent blocking UI when Ollama runtime is not available -ai.enabled=true - -# RSS feeds for Weekly Updates (comma-separated) -news.rss.urls=https://winn22.org/feed,https://legis.delaware.gov/RSSFeed -# Refresh interval in milliseconds (3600000 = 1 hour) -news.rss.refresh-interval=3600000 - - - diff --git a/backend/target/classes/org/firststep/backend/FirstStepApplication.class b/backend/target/classes/org/firststep/backend/FirstStepApplication.class deleted file mode 100644 index ad29fe6..0000000 Binary files a/backend/target/classes/org/firststep/backend/FirstStepApplication.class and /dev/null differ diff --git a/backend/target/classes/org/firststep/backend/controller/DecisionController.class b/backend/target/classes/org/firststep/backend/controller/DecisionController.class deleted file mode 100644 index 546b56e..0000000 Binary files a/backend/target/classes/org/firststep/backend/controller/DecisionController.class and /dev/null differ diff --git a/backend/target/classes/org/firststep/backend/controller/NewsController.class b/backend/target/classes/org/firststep/backend/controller/NewsController.class deleted file mode 100644 index f172085..0000000 Binary files a/backend/target/classes/org/firststep/backend/controller/NewsController.class and /dev/null differ diff --git a/backend/target/classes/org/firststep/backend/controller/ResourceController.class b/backend/target/classes/org/firststep/backend/controller/ResourceController.class deleted file mode 100644 index 225b2f6..0000000 Binary files a/backend/target/classes/org/firststep/backend/controller/ResourceController.class and /dev/null differ diff --git a/backend/target/classes/org/firststep/backend/dto/Citation.class b/backend/target/classes/org/firststep/backend/dto/Citation.class deleted file mode 100644 index b776a1d..0000000 Binary files a/backend/target/classes/org/firststep/backend/dto/Citation.class and /dev/null differ diff --git a/backend/target/classes/org/firststep/backend/dto/DecisionRequest.class b/backend/target/classes/org/firststep/backend/dto/DecisionRequest.class deleted file mode 100644 index 82dfad2..0000000 Binary files a/backend/target/classes/org/firststep/backend/dto/DecisionRequest.class and /dev/null differ diff --git a/backend/target/classes/org/firststep/backend/dto/DecisionResponse.class b/backend/target/classes/org/firststep/backend/dto/DecisionResponse.class deleted file mode 100644 index 45e05d1..0000000 Binary files a/backend/target/classes/org/firststep/backend/dto/DecisionResponse.class and /dev/null differ diff --git a/backend/target/classes/org/firststep/backend/dto/DecisionStep.class b/backend/target/classes/org/firststep/backend/dto/DecisionStep.class deleted file mode 100644 index cc121cb..0000000 Binary files a/backend/target/classes/org/firststep/backend/dto/DecisionStep.class and /dev/null differ diff --git a/backend/target/classes/org/firststep/backend/model/NewsItem.class b/backend/target/classes/org/firststep/backend/model/NewsItem.class deleted file mode 100644 index 4a39ade..0000000 Binary files a/backend/target/classes/org/firststep/backend/model/NewsItem.class and /dev/null differ diff --git a/backend/target/classes/org/firststep/backend/model/Resource$Location.class b/backend/target/classes/org/firststep/backend/model/Resource$Location.class deleted file mode 100644 index 41b235c..0000000 Binary files a/backend/target/classes/org/firststep/backend/model/Resource$Location.class and /dev/null differ diff --git a/backend/target/classes/org/firststep/backend/model/Resource$Phone.class b/backend/target/classes/org/firststep/backend/model/Resource$Phone.class deleted file mode 100644 index 9166775..0000000 Binary files a/backend/target/classes/org/firststep/backend/model/Resource$Phone.class and /dev/null differ diff --git a/backend/target/classes/org/firststep/backend/model/Resource$Website.class b/backend/target/classes/org/firststep/backend/model/Resource$Website.class deleted file mode 100644 index f79d9c1..0000000 Binary files a/backend/target/classes/org/firststep/backend/model/Resource$Website.class and /dev/null differ diff --git a/backend/target/classes/org/firststep/backend/model/Resource.class b/backend/target/classes/org/firststep/backend/model/Resource.class deleted file mode 100644 index c064589..0000000 Binary files a/backend/target/classes/org/firststep/backend/model/Resource.class and /dev/null differ diff --git a/backend/target/classes/org/firststep/backend/service/DecisionAgentService$NewsScore.class b/backend/target/classes/org/firststep/backend/service/DecisionAgentService$NewsScore.class deleted file mode 100644 index 443c2be..0000000 Binary files a/backend/target/classes/org/firststep/backend/service/DecisionAgentService$NewsScore.class and /dev/null differ diff --git a/backend/target/classes/org/firststep/backend/service/DecisionAgentService$NewsServiceLike.class b/backend/target/classes/org/firststep/backend/service/DecisionAgentService$NewsServiceLike.class deleted file mode 100644 index 67b2b27..0000000 Binary files a/backend/target/classes/org/firststep/backend/service/DecisionAgentService$NewsServiceLike.class and /dev/null differ diff --git a/backend/target/classes/org/firststep/backend/service/DecisionAgentService$ResourceScore.class b/backend/target/classes/org/firststep/backend/service/DecisionAgentService$ResourceScore.class deleted file mode 100644 index 00a1478..0000000 Binary files a/backend/target/classes/org/firststep/backend/service/DecisionAgentService$ResourceScore.class and /dev/null differ diff --git a/backend/target/classes/org/firststep/backend/service/DecisionAgentService$ResourceServiceLike.class b/backend/target/classes/org/firststep/backend/service/DecisionAgentService$ResourceServiceLike.class deleted file mode 100644 index 715f1e2..0000000 Binary files a/backend/target/classes/org/firststep/backend/service/DecisionAgentService$ResourceServiceLike.class and /dev/null differ diff --git a/backend/target/classes/org/firststep/backend/service/DecisionAgentService.class b/backend/target/classes/org/firststep/backend/service/DecisionAgentService.class deleted file mode 100644 index fbadbea..0000000 Binary files a/backend/target/classes/org/firststep/backend/service/DecisionAgentService.class and /dev/null differ diff --git a/backend/target/classes/org/firststep/backend/service/NewsService$1.class b/backend/target/classes/org/firststep/backend/service/NewsService$1.class deleted file mode 100644 index 24f0191..0000000 Binary files a/backend/target/classes/org/firststep/backend/service/NewsService$1.class and /dev/null differ diff --git a/backend/target/classes/org/firststep/backend/service/NewsService.class b/backend/target/classes/org/firststep/backend/service/NewsService.class deleted file mode 100644 index 9f52b01..0000000 Binary files a/backend/target/classes/org/firststep/backend/service/NewsService.class and /dev/null differ diff --git a/backend/target/classes/org/firststep/backend/service/OllamaService.class b/backend/target/classes/org/firststep/backend/service/OllamaService.class deleted file mode 100644 index 8fa9553..0000000 Binary files a/backend/target/classes/org/firststep/backend/service/OllamaService.class and /dev/null differ diff --git a/backend/target/classes/org/firststep/backend/service/ResourceService$1.class b/backend/target/classes/org/firststep/backend/service/ResourceService$1.class deleted file mode 100644 index 27912a8..0000000 Binary files a/backend/target/classes/org/firststep/backend/service/ResourceService$1.class and /dev/null differ diff --git a/backend/target/classes/org/firststep/backend/service/ResourceService$2.class b/backend/target/classes/org/firststep/backend/service/ResourceService$2.class deleted file mode 100644 index 0ff48c3..0000000 Binary files a/backend/target/classes/org/firststep/backend/service/ResourceService$2.class and /dev/null differ diff --git a/backend/target/classes/org/firststep/backend/service/ResourceService$3.class b/backend/target/classes/org/firststep/backend/service/ResourceService$3.class deleted file mode 100644 index 752498e..0000000 Binary files a/backend/target/classes/org/firststep/backend/service/ResourceService$3.class and /dev/null differ diff --git a/backend/target/classes/org/firststep/backend/service/ResourceService.class b/backend/target/classes/org/firststep/backend/service/ResourceService.class deleted file mode 100644 index e73e236..0000000 Binary files a/backend/target/classes/org/firststep/backend/service/ResourceService.class and /dev/null differ diff --git a/backend/target/classes/org/firststep/backend/service/RssFeedService.class b/backend/target/classes/org/firststep/backend/service/RssFeedService.class deleted file mode 100644 index 86efff1..0000000 Binary files a/backend/target/classes/org/firststep/backend/service/RssFeedService.class and /dev/null differ diff --git a/backend/target/classes/org/firststep/backend/service/RssFeedSource.class b/backend/target/classes/org/firststep/backend/service/RssFeedSource.class deleted file mode 100644 index b40102a..0000000 Binary files a/backend/target/classes/org/firststep/backend/service/RssFeedSource.class and /dev/null differ diff --git a/backend/target/classes/static/app.js b/backend/target/classes/static/app.js deleted file mode 100644 index 0383c93..0000000 --- a/backend/target/classes/static/app.js +++ /dev/null @@ -1,848 +0,0 @@ -// ===== Translations ===== -const STRINGS = { - en: { - getHelp: "Get Help", - communityInfo: "Community Info", - announcements: "Announcements", - communityInfoSoon: "Community Info section coming soon!", - announcementsSoon: "Announcements section coming soon!", - heroTitle: "What do you need help with today?", - heroSubtitle: "Find housing, essentials, community programs and local updates in one trusted place.", - housingHelp: "Housing Help", - housingHelpSub: "Home and Rental Assistance, Shelters, Home Repairs", - housingHelpDesc: "Find programs and local organizations that can help you find, buy or rent a place to live. Browse emergency shelter options, rental assistance programs and homeownership or mortgage resources. Listings include contact details and eligibility information so you can act quickly.", - essentials: "Free / Low-Cost Essentials", - essentialsSub: "Food, Furniture, Clothing", - essentialsDesc: "Check out these local programs and nonprofits offering furniture, utilities, repairs for free or at a low cost. Make your home more comfortable and safe with a few simple steps.", - seasonal: "Community Resources", - seasonalSub: "Programs, Events and Community Opportunities", - weeklyUpdates: "Weekly Updates", - weeklyUpdatesSub: "News and Changes Impacting You", - weeklyUpdatesDesc: "Stay up to date on the rules, public meetings and changes that affect housing, benefits and community services. Read the highlights and learn about important deadlines, new requirements and policy updates so you can participate and plan ahead.", - aiTitle: "AI Guidance", - aiSubtitle: "Ask a question in natural language to find resources", - aiPlaceholder: "E.g., I need rental help near Wilmington for seniors", - aiButton: "Get Help", - aiUrgent: "🚨 Urgent", - aiHousing: "🏠 Housing", - aiEssentials: "🛒 Essentials", - latestUpdates: "Latest Updates", - refineSearch: "Refine Your Search", - filterSubtitle: "Choose filters to narrow results.", - viewResults: "View Results", - backHome: "← Back to Home", - backResults: "← Back to Results", - back: "← Back", - noResults: "No matching resources found.", - loading: "Loading...", - loadingAI: "Finding resources for you…", - phone: "Phone", - address: "Location", - website: "Visit Website →", - eligibility: "Who Qualifies", - category: "Category", - urgency: "Urgency", - about: "About", - sources: "Sources", - viewDetails: "View Details →", - callNow: "📞 Call", - aiSummary: "AI Summary & Guidance", - aiAnalyzed: "AI analyzed your query and found relevant resources", - }, - es: { - getHelp: "Obtener Ayuda", - communityInfo: "Info Comunitaria", - announcements: "Anuncios", - communityInfoSoon: "¡La sección de Información Comunitaria estará disponible pronto!", - announcementsSoon: "¡La sección de Anuncios estará disponible pronto!", - heroTitle: "¿Con qué necesitas ayuda hoy?", - heroSubtitle: "Encuentra vivienda, artículos esenciales, programas comunitarios y noticias locales en un solo lugar de confianza.", - housingHelp: "Ayuda con Vivienda", - housingHelpSub: "Asistencia para Alquiler, Refugios, Reparaciones del Hogar", - housingHelpDesc: "Encuentra programas y organizaciones locales que pueden ayudarte a encontrar, comprar o alquilar un lugar donde vivir.", - essentials: "Artículos Esenciales Gratis / Económicos", - essentialsSub: "Comida, Muebles, Ropa", - essentialsDesc: "Consulta estos programas locales y organizaciones sin fines de lucro que ofrecen muebles, servicios y reparaciones de forma gratuita o a bajo costo.", - seasonal: "Recursos Comunitarios", - seasonalSub: "Programas, Eventos y Oportunidades Comunitarias", - weeklyUpdates: "Actualizaciones Semanales", - weeklyUpdatesSub: "Noticias y Cambios que te Afectan", - weeklyUpdatesDesc: "Mantente al día sobre las reglas, reuniones públicas y cambios que afectan la vivienda, los beneficios y los servicios comunitarios.", - aiTitle: "Orientación con IA", - aiSubtitle: "Haz una pregunta para encontrar recursos", - aiPlaceholder: "Ej., Necesito ayuda con el alquiler cerca de Wilmington para personas mayores", - aiButton: "Obtener Ayuda", - aiUrgent: "🚨 Urgente", - aiHousing: "🏠 Vivienda", - aiEssentials: "🛒 Esenciales", - latestUpdates: "Últimas Actualizaciones", - refineSearch: "Refinar Búsqueda", - filterSubtitle: "Elige filtros para reducir resultados.", - viewResults: "Ver Resultados", - backHome: "← Volver al Inicio", - backResults: "← Volver a Resultados", - back: "← Volver", - noResults: "No se encontraron recursos.", - loading: "Cargando...", - loadingAI: "Buscando recursos para ti…", - phone: "Teléfono", - address: "Ubicación", - website: "Visitar Sitio Web →", - eligibility: "Quién Califica", - category: "Categoría", - urgency: "Urgencia", - about: "Acerca de", - sources: "Fuentes", - viewDetails: "Ver Detalles →", - callNow: "📞 Llamar", - aiSummary: "Resumen y Orientación con IA", - aiAnalyzed: "La IA analizó tu pregunta y encontró recursos relevantes", - } -}; - -let currentLanguage = "en"; - -function t(key) { - return (STRINGS[currentLanguage] && STRINGS[currentLanguage][key]) || STRINGS.en[key] || key; -} - -function applyLanguage() { - document.querySelectorAll("[data-i18n]").forEach(el => { - const key = el.getAttribute("data-i18n"); - if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") { - el.placeholder = t(key); - } else { - el.textContent = t(key); - } - }); - document.getElementById("language-toggle").textContent = - currentLanguage === "en" ? "ES" : "EN"; -} - -// ===== Page Navigation ===== -const homeLogoLink = document.getElementById("home-logo-link"); -const navGetHelp = document.getElementById("nav-get-help"); -const navCommunityInfo = document.getElementById("nav-community-info"); -const navAnnouncements = document.getElementById("nav-announcements"); -const backFromFilterButton = document.getElementById("back-from-filter-button"); -const backHomeButtonResults = document.getElementById("back-home-button-results"); - -function goHome() { - document.getElementById("home-screen").style.display = "block"; - document.getElementById("filter-screen").style.display = "none"; - document.getElementById("results-screen").style.display = "none"; - document.getElementById("detail-screen").style.display = "none"; - window.scrollTo(0, 0); -} - -homeLogoLink.addEventListener("click", (e) => { e.preventDefault(); goHome(); }); - -const aiBannerHeader = document.getElementById("ai-banner-header"); -aiBannerHeader.addEventListener("click", () => { - goHome(); - setTimeout(() => { - document.getElementById("ai-guidance-home").scrollIntoView({ behavior: "smooth" }); - document.getElementById("ai-question").focus(); - }, 50); -}); -aiBannerHeader.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") aiBannerHeader.click(); }); - -navGetHelp.addEventListener("click", (e) => { - e.preventDefault(); - goHome(); - navGetHelp.classList.add("active"); - navCommunityInfo.classList.remove("active"); - navAnnouncements.classList.remove("active"); -}); - -navCommunityInfo.addEventListener("click", (e) => { - e.preventDefault(); - navGetHelp.classList.remove("active"); - navCommunityInfo.classList.add("active"); - navAnnouncements.classList.remove("active"); - showSeasonalResources(); -}); - -navAnnouncements.addEventListener("click", (e) => { - e.preventDefault(); - navGetHelp.classList.remove("active"); - navCommunityInfo.classList.remove("active"); - navAnnouncements.classList.add("active"); - loadNewsUpdates(); -}); - -// ===== Language Toggle ===== -document.getElementById("language-toggle").addEventListener("click", () => { - currentLanguage = currentLanguage === "en" ? "es" : "en"; - applyLanguage(); -}); - -// ===== Accessibility Controls ===== -document.getElementById("contrast-button").addEventListener("click", () => { - document.body.classList.toggle("high-contrast"); - localStorage.setItem("high-contrast", document.body.classList.contains("high-contrast")); -}); - -document.getElementById("increase-text-button").addEventListener("click", () => { - const current = parseFloat(getComputedStyle(document.documentElement).fontSize); - const next = Math.min(current + 2, 24); - document.documentElement.style.fontSize = next + "px"; - localStorage.setItem("font-size", next + "px"); -}); - -document.getElementById("decrease-text-button").addEventListener("click", () => { - const current = parseFloat(getComputedStyle(document.documentElement).fontSize); - const next = Math.max(current - 2, 12); - document.documentElement.style.fontSize = next + "px"; - localStorage.setItem("font-size", next + "px"); -}); - -window.addEventListener("DOMContentLoaded", () => { - if (localStorage.getItem("high-contrast") === "true") { - document.body.classList.add("high-contrast"); - } - const savedFontSize = localStorage.getItem("font-size"); - if (savedFontSize) document.documentElement.style.fontSize = savedFontSize; - loadSidebarNews(); - applyLanguage(); -}); - -// ===== DOM References ===== -const resultsContainer = document.getElementById("results"); -const resultsScreen = document.getElementById("results-screen"); -const filterScreen = document.getElementById("filter-screen"); -const urgentFilterButton = document.getElementById("urgent-filter"); -const continueButton = document.getElementById("continue-button"); -const newsResultsContainer = document.getElementById("news-results"); -const seasonalResultsContainer = document.getElementById("seasonal-results"); -const essentialsResultsContainer = document.getElementById("essentials-results"); -const homeScreen = document.getElementById("home-screen"); -const detailScreen = document.getElementById("detail-screen"); -const detailView = document.getElementById("detail-view"); -const backResultsButton = document.getElementById("back-results-button"); -const backHomeButton = document.getElementById("back-home-button"); - -let urgentFilterSelected = false; - -// ===== Category Navigation ===== -document.getElementById("housing-help-button").addEventListener("click", () => { - homeScreen.style.display = "none"; - filterScreen.style.display = "block"; - resultsContainer.innerHTML = ""; - window.scrollTo(0, 0); -}); - -continueButton.addEventListener("click", () => { - filterScreen.style.display = "none"; - loadHousingResources(); -}); - -urgentFilterButton.addEventListener("click", () => { - urgentFilterSelected = !urgentFilterSelected; - urgentFilterButton.classList.toggle("selected"); -}); - -document.getElementById("weekly-updates-button-home").addEventListener("click", loadNewsUpdates); -document.getElementById("seasonal-resources-button").addEventListener("click", showSeasonalResources); -document.getElementById("essentials-button").addEventListener("click", loadEssentialsResources); - -backHomeButton?.addEventListener("click", goHome); -backResultsButton.addEventListener("click", hideDetailScreen); -backFromFilterButton?.addEventListener("click", goHome); -backHomeButtonResults?.addEventListener("click", goHome); - -// ===== AI Guidance ===== -const aiQuestionEl = document.getElementById("ai-question"); -const aiSubmitBtn = document.getElementById("ai-submit"); -const aiOutputEl = document.getElementById("ai-output"); - -let aiUrgent = false; -let aiPreferredCategories = []; - -function setAiChipSelected(btn, selected) { - btn.classList.toggle("selected", selected); -} - -document.getElementById("ai-urgent").addEventListener("click", () => { - aiUrgent = !aiUrgent; - setAiChipSelected(document.getElementById("ai-urgent"), aiUrgent); -}); - -document.getElementById("ai-housing").addEventListener("click", () => { - const idx = aiPreferredCategories.indexOf("housing"); - if (idx >= 0) aiPreferredCategories.splice(idx, 1); - else aiPreferredCategories.push("housing"); - setAiChipSelected(document.getElementById("ai-housing"), aiPreferredCategories.includes("housing")); -}); - -document.getElementById("ai-essentials").addEventListener("click", () => { - const idx = aiPreferredCategories.indexOf("essentials"); - if (idx >= 0) aiPreferredCategories.splice(idx, 1); - else aiPreferredCategories.push("essentials"); - setAiChipSelected(document.getElementById("ai-essentials"), aiPreferredCategories.includes("essentials")); -}); - -aiSubmitBtn.addEventListener("click", submitDecision); -aiQuestionEl.addEventListener("keydown", (e) => { if (e.key === "Enter") submitDecision(); }); - -document.getElementById("ai-reset").addEventListener("click", () => { - aiQuestionEl.value = ""; - aiOutputEl.innerHTML = ""; - aiUrgent = false; - aiPreferredCategories = []; - setAiChipSelected(document.getElementById("ai-urgent"), false); - setAiChipSelected(document.getElementById("ai-housing"), false); - setAiChipSelected(document.getElementById("ai-essentials"), false); - aiQuestionEl.focus(); -}); - -async function submitDecision() { - const userQuery = (aiQuestionEl.value || "").trim(); - if (!userQuery) { - aiOutputEl.innerHTML = `

${t("aiPlaceholder")}

`; - return; - } - - aiOutputEl.innerHTML = ` -
-
- ${t("loadingAI")} -
`; - - try { - const res = await fetch("/api/decide", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - userQuery, - urgent: aiUrgent, - preferredCategories: aiPreferredCategories - }) - }); - - if (!res.ok) { - const text = await res.text(); - throw new Error("HTTP " + res.status + ": " + text); - } - - const data = await res.json(); - renderDecisionResponse(data); - - } catch (err) { - console.error(err); - aiOutputEl.innerHTML = `

Unable to get AI guidance: ${err.message || String(err)}

`; - } -} - -function renderDecisionResponse(data) { - const title = data?.answerTitle || "Guidance"; - const notes = data?.notes || ""; - const steps = data?.steps || []; - const citations = data?.citations || []; - - let stepsHtml = ""; - if (steps.length > 0) { - stepsHtml = steps - .sort((a, b) => (a.order || 0) - (b.order || 0)) - .map((s, i) => ` -
-
${i + 1}
-
-
${s.title || "Step"}: ${s.action || ""}
- ${s.why ? `
Why: ${s.why}
` : ""} -
-
- `).join(""); - } else { - stepsHtml = `

${notes || "No specific steps available for this query."}

`; - } - - const citationsHtml = citations.length > 0 - ? `
- ${t("sources")}: -
    ${citations.map(c => `
  • ${c.sourceType || ""}: ${c.label || c.id || ""}
  • `).join("")}
-
` - : ""; - - aiOutputEl.innerHTML = ` -
-
- 🤖 -

${title}

-
- ${notes && steps.length > 0 ? `

${notes}

` : ""} -
${stepsHtml}
- ${citationsHtml} -
- `; -} - -// ===== Resource Loading ===== -async function loadHousingResources() { - newsResultsContainer.style.display = "none"; - essentialsResultsContainer.style.display = "none"; - seasonalResultsContainer.style.display = "none"; - detailScreen.style.display = "none"; - resultsContainer.style.display = "block"; - resultsContainer.innerHTML = `

${t("loading")}

`; - - try { - const response = await fetch("/api/resources"); - const resources = await response.json(); - - let housingResources = resources.filter(r => - r.category && r.category.toLowerCase().includes("housing") - ); - - if (urgentFilterSelected) { - housingResources = housingResources.filter(r => { - const u = (r.urgency || "").toLowerCase(); - return u === "emergency" || u === "time-limited"; - }); - } - - displayResources(housingResources); - showResultsScreen(); - - } catch (error) { - console.error(error); - resultsContainer.innerHTML = "

Unable to load resources.

"; - } -} - -async function loadEssentialsResources() { - resultsContainer.style.display = "none"; - newsResultsContainer.style.display = "none"; - seasonalResultsContainer.style.display = "none"; - detailScreen.style.display = "none"; - essentialsResultsContainer.style.display = "block"; - essentialsResultsContainer.innerHTML = `

${t("loading")}

`; - - try { - const response = await fetch("/api/resources"); - const resources = await response.json(); - const free = resources.filter(r => r.cost && r.cost.toLowerCase() === "free"); - displayEssentials(free); - showResultsScreen(); - } catch (error) { - console.error(error); - essentialsResultsContainer.innerHTML = "

Unable to load resources.

"; - } -} - -async function loadNewsUpdates() { - resultsContainer.style.display = "none"; - essentialsResultsContainer.style.display = "none"; - seasonalResultsContainer.style.display = "none"; - detailScreen.style.display = "none"; - newsResultsContainer.style.display = "block"; - newsResultsContainer.innerHTML = `

${t("loading")}

`; - - try { - const response = await fetch("/api/news"); - const newsItems = await response.json(); - displayNews(newsItems); - showResultsScreen(); - } catch (error) { - console.error(error); - newsResultsContainer.innerHTML = "

Unable to load updates.

"; - } - return Promise.resolve(); -} - -async function loadSidebarNews() { - try { - const response = await fetch("/api/news"); - const items = await response.json(); - const newsResults = document.getElementById("sidebar-news"); - newsResults.innerHTML = ""; - - items.filter(item => item.active).slice(0, 3).forEach(item => { - const card = document.createElement("div"); - card.className = "news-item"; - card.innerHTML = ` -

${item.headline}

-
${item.published || "Latest"}
- `; - card.addEventListener("click", () => { - loadNewsUpdates().then(() => showNewsDetail(item)); - }); - newsResults.appendChild(card); - }); - } catch (error) { - console.error("Sidebar news failed to load:", error); - document.getElementById("sidebar-news").innerHTML = - '

Unable to load updates

'; - } -} - -// ===== Screen Management ===== -let activeResultsContainer = null; - -function showResultsScreen() { - homeScreen.style.display = "none"; - filterScreen.style.display = "none"; - detailScreen.style.display = "none"; - resultsScreen.style.display = "block"; - window.scrollTo(0, 0); -} - -function showDetailScreen() { - // Hide whichever results container is currently visible - [resultsContainer, newsResultsContainer, essentialsResultsContainer, seasonalResultsContainer].forEach(el => { - if (el.style.display !== "none") { - activeResultsContainer = el; - el.style.display = "none"; - } - }); - detailScreen.style.display = "block"; - window.scrollTo(0, 0); -} - -function hideDetailScreen() { - detailScreen.style.display = "none"; - if (activeResultsContainer) { - activeResultsContainer.style.display = "block"; - } else { - resultsContainer.style.display = "block"; - } - window.scrollTo(0, 0); -} - -function renderPageHeader(title, description) { - return ` - - `; -} - -function urgencyClass(urgency) { - const u = (urgency || "standard").toLowerCase().replace(/\s+/g, "-"); - if (u === "emergency") return "urgency-emergency"; - if (u === "time-limited") return "urgency-time-limited"; - return "urgency-standard"; -} - -// ===== Display Functions ===== -function displayResources(resources) { - resultsContainer.innerHTML = renderPageHeader(t("housingHelp"), t("housingHelpDesc")); - - if (resources.length === 0) { - resultsContainer.innerHTML += `

${t("noResults")}

`; - return; - } - - resources.forEach(resource => { - const phone = resource.phones?.[0]?.number; - const urgency = resource.urgency || "Standard"; - - const card = document.createElement("div"); - card.className = "resource-card"; - card.innerHTML = ` -
-

${resource.organization}

- ${urgency} -
-

${resource.summary || ""}

- ${phone ? `

📞 ${phone}

` : ""} -

${t("viewDetails")}

- `; - card.addEventListener("click", () => showResourceDetails(resource)); - resultsContainer.appendChild(card); - }); -} - -function displayEssentials(resources) { - essentialsResultsContainer.innerHTML = renderPageHeader(t("essentials"), t("essentialsDesc")); - - if (resources.length === 0) { - essentialsResultsContainer.innerHTML += `

${t("noResults")}

`; - return; - } - - const grouped = {}; - resources.forEach(r => { - const cat = r.category || "Other"; - if (!grouped[cat]) grouped[cat] = []; - grouped[cat].push(r); - }); - - const categories = Object.keys(grouped); - - // Category anchor nav at the top - const anchorSlug = cat => cat.toLowerCase().replace(/[^a-z0-9]+/g, "-"); - const navHtml = ` -
- Jump to: - ${categories.map(cat => ` - ${cat} - `).join("")} -
- `; - essentialsResultsContainer.innerHTML += navHtml; - - Object.entries(grouped).forEach(([category, items]) => { - const slug = anchorSlug(category); - essentialsResultsContainer.innerHTML += `

${category}

`; - items.forEach(resource => { - const phone = resource.phones?.[0]?.number; - const card = document.createElement("div"); - card.className = "resource-card"; - card.innerHTML = ` -
-

${resource.organization}

- Free -
-

${resource.summary || ""}

- ${phone ? `

📞 ${phone}

` : ""} -

${t("viewDetails")}

- `; - card.addEventListener("click", () => showResourceDetails(resource)); - essentialsResultsContainer.appendChild(card); - }); - }); -} - -function displayNews(newsItems) { - newsResultsContainer.innerHTML = renderPageHeader(t("weeklyUpdates"), t("weeklyUpdatesDesc")); - - newsItems.forEach(item => { - const cats = (item.category_tags || []).join(" · "); - const sourceLink = item.source_url - ? ` - ${item.source_name} ↗ - ` - : item.source_name; - const card = document.createElement("div"); - card.className = "resource-card"; - card.innerHTML = ` - ${cats || "General"} -

${item.headline}

-

${item.summary}

-

Why this matters: ${item.why_it_matters}

-

${sourceLink} · ${item.published}

-

${t("viewDetails")}

- `; - card.addEventListener("click", () => showNewsDetail(item)); - newsResultsContainer.appendChild(card); - }); -} - -function showNewsDetail(item) { - const cats = (item.category_tags || []).join(" · "); - const sourceLink = item.source_url - ? `Read More →` - : ""; - - detailView.innerHTML = ` -
-

${item.headline}

- ${cats || "General"} -
- - ${item.body ? ` -
-
${t("about")}
-
${item.body}
-
` : item.summary ? ` -
-
${t("about")}
-
${item.summary}
-
` : ""} - - ${item.why_it_matters ? ` -
-
Why This Matters
-
${item.why_it_matters}
-
` : ""} - -
-
Source
-
- ${item.source_name}${item.published ? " · " + item.published : ""} - ${sourceLink ? `
${sourceLink}` : ""} -
-
- `; - - showDetailScreen(); -} - -async function showSeasonalResources() { - resultsContainer.style.display = "none"; - newsResultsContainer.style.display = "none"; - essentialsResultsContainer.style.display = "none"; - detailScreen.style.display = "none"; - - const carousel = document.getElementById("seasonal-carousel"); - carousel.innerHTML = `

Loading...

`; - seasonalResultsContainer.style.display = "block"; - showResultsScreen(); - - try { - const response = await fetch("/api/seasonal-images"); - const paths = await response.json(); - carousel.innerHTML = ""; - if (paths.length === 0) { - carousel.innerHTML = `

No community resources available.

`; - } else { - paths.forEach(src => { - const filename = src.split("/").pop().replace(/\.[^.]+$/, ""); - carousel.innerHTML += ` - - `; - }); - } - } catch (error) { - console.error("Failed to load seasonal images:", error); - carousel.innerHTML = `

Unable to load resources.

`; - } -} - -function showResourceDetails(resource) { - const phone = resource.phones?.[0]?.number; - const website = resource.websites?.[0]?.url; - const location = resource.locations?.[0]; - const urgency = resource.urgency || "Standard"; - - let addressHtml = ""; - if (location && !location.confidential) { - if (location.address) { - addressHtml = `${location.address}, ${location.city}, ${location.state} ${location.zip || ""}`; - } else if (location.city) { - addressHtml = `${location.city}, ${location.state}`; - } - } - - detailView.innerHTML = ` -
-

${resource.organization}

- ${urgency} -
- - ${resource.description || resource.summary ? ` -
-
${t("about")}
-
${resource.description || resource.summary}
-
` : ""} - - ${resource.eligibility ? ` -
-
${t("eligibility")}
-
${resource.eligibility}
-
` : ""} - - ${resource.category ? ` -
-
${t("category")}
-
${resource.category}${resource.subcategory ? " · " + resource.subcategory : ""}
-
` : ""} - - ${phone ? ` -
-
${t("phone")}
- -
` : ""} - - ${addressHtml ? ` -
-
${t("address")}
-
${addressHtml}
-
` : ""} - - ${website ? ` -
-
${t("website")}
- -
` : ""} - `; - - showDetailScreen(); -} - -// ===== Results Page AI Widget ===== -const resultsAiQuestion = document.getElementById("results-ai-question"); -const resultsAiSubmit = document.getElementById("results-ai-submit"); -const resultsAiOutput = document.getElementById("results-ai-output"); - -async function submitResultsAi(query) { - const userQuery = (query || resultsAiQuestion.value || "").trim(); - if (!userQuery) return; - resultsAiQuestion.value = userQuery; - resultsAiOutput.innerHTML = ` -
-
- ${t("loadingAI")} -
`; - - try { - const res = await fetch("/api/decide", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ userQuery, urgent: false, preferredCategories: [] }) - }); - if (!res.ok) throw new Error("HTTP " + res.status); - const data = await res.json(); - renderResultsAiResponse(data); - } catch (err) { - resultsAiOutput.innerHTML = `

Unable to get AI guidance: ${err.message}

`; - } -} - -function renderResultsAiResponse(data) { - const title = data?.answerTitle || "Guidance"; - const steps = data?.steps || []; - const notes = data?.notes || ""; - - let stepsHtml = ""; - if (steps.length > 0) { - stepsHtml = steps - .sort((a, b) => (a.order || 0) - (b.order || 0)) - .map((s, i) => ` -
-
${i + 1}
-
-
${s.title || "Step"}: ${s.action || ""}
- ${s.why ? `
Why: ${s.why}
` : ""} -
-
- `).join(""); - } else { - stepsHtml = `

${notes || "No specific steps available for this query."}

`; - } - - resultsAiOutput.innerHTML = ` -
-
- 🤖 -

${title}

-
- ${notes && steps.length > 0 ? `

${notes}

` : ""} -
${stepsHtml}
-
- `; -} - -resultsAiSubmit.addEventListener("click", () => submitResultsAi()); -resultsAiQuestion.addEventListener("keydown", (e) => { if (e.key === "Enter") submitResultsAi(); }); - -document.querySelectorAll(".results-ai-chip").forEach(chip => { - chip.addEventListener("click", () => { - const query = chip.getAttribute("data-query"); - submitResultsAi(query); - chip.closest(".results-ai-chips").querySelectorAll(".results-ai-chip").forEach(c => c.classList.remove("selected")); - chip.classList.add("selected"); - }); -}); - -document.getElementById("results-ai-reset").addEventListener("click", () => { - resultsAiQuestion.value = ""; - resultsAiOutput.innerHTML = ""; - document.querySelectorAll(".results-ai-chip").forEach(c => c.classList.remove("selected")); - resultsAiQuestion.focus(); -}); diff --git a/backend/target/classes/static/images/seasonal/Disablility Info.jpg b/backend/target/classes/static/images/seasonal/Disablility Info.jpg deleted file mode 100644 index 7c9456b..0000000 Binary files a/backend/target/classes/static/images/seasonal/Disablility Info.jpg and /dev/null differ diff --git a/backend/target/classes/static/images/seasonal/Eviction Help.png b/backend/target/classes/static/images/seasonal/Eviction Help.png deleted file mode 100644 index cd7568e..0000000 Binary files a/backend/target/classes/static/images/seasonal/Eviction Help.png and /dev/null differ diff --git a/backend/target/classes/static/images/seasonal/Fundraiser.jpg b/backend/target/classes/static/images/seasonal/Fundraiser.jpg deleted file mode 100644 index 1b2a9d7..0000000 Binary files a/backend/target/classes/static/images/seasonal/Fundraiser.jpg and /dev/null differ diff --git a/backend/target/classes/static/images/seasonal/Furniture.jpg b/backend/target/classes/static/images/seasonal/Furniture.jpg deleted file mode 100644 index 777e1ed..0000000 Binary files a/backend/target/classes/static/images/seasonal/Furniture.jpg and /dev/null differ diff --git a/backend/target/classes/static/images/seasonal/Health Fair.jpg b/backend/target/classes/static/images/seasonal/Health Fair.jpg deleted file mode 100644 index df28350..0000000 Binary files a/backend/target/classes/static/images/seasonal/Health Fair.jpg and /dev/null differ diff --git a/backend/target/classes/static/images/seasonal/Volunteer.jpg b/backend/target/classes/static/images/seasonal/Volunteer.jpg deleted file mode 100644 index e5efb28..0000000 Binary files a/backend/target/classes/static/images/seasonal/Volunteer.jpg and /dev/null differ diff --git a/backend/target/classes/static/images/seasonal/Youth.jpg b/backend/target/classes/static/images/seasonal/Youth.jpg deleted file mode 100644 index 26ba974..0000000 Binary files a/backend/target/classes/static/images/seasonal/Youth.jpg and /dev/null differ diff --git a/backend/target/classes/static/index.html b/backend/target/classes/static/index.html deleted file mode 100644 index 14b36e3..0000000 --- a/backend/target/classes/static/index.html +++ /dev/null @@ -1,193 +0,0 @@ - - - - - - First Step - Trusted local guidance for Wilmington - - - - - - - - - - -
- - -
- -
-

What do you need help with today?

-

Find housing, essentials, community programs and local updates in one trusted place.

-
- -
- -
-
- - - - - - - - - -
- - -
-

AI Guidance

-

Ask a question in natural language to find resources

-
- - - -
-
- - - -
-
-
-
- - - - -
- -
- - - - - - - - - - -
- - - - - diff --git a/backend/target/classes/static/styles.css b/backend/target/classes/static/styles.css deleted file mode 100644 index 48b0f4d..0000000 --- a/backend/target/classes/static/styles.css +++ /dev/null @@ -1,983 +0,0 @@ -/* ===== Root & Base ===== */ -:root { - --primary-color: #1a5c38; - --primary-light: #2d7d4a; - --accent-color: #e07b39; - --success-color: #2d7d4a; - --warning-color: #d97706; - --error-color: #b91c1c; - --bg-light: #f5f0eb; - --bg-lighter: #faf8f5; - --border-color: #e5ddd5; - --text-primary: #1c1917; - --text-secondary: #6b7280; - --text-light: #9ca3af; - --shadow-sm: 0 1px 2px 0 rgba(0,0,0,0.05); - --shadow-md: 0 4px 6px -1px rgba(0,0,0,0.1); - --shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.1); - --transition: all 0.2s ease; - --radius: 12px; -} - -* { box-sizing: border-box; } -html { scroll-behavior: smooth; } - -body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; - background-color: var(--bg-lighter); - color: var(--text-primary); - margin: 0; - padding: 0; - line-height: 1.6; - font-size: 1rem; -} - -/* ===== Header ===== */ -.site-header { - background: white; - border-bottom: 1px solid var(--border-color); - position: sticky; - top: 0; - z-index: 100; - box-shadow: var(--shadow-sm); -} - -.header-content { - display: flex; - justify-content: space-between; - align-items: center; - max-width: 1200px; - margin: 0 auto; - padding: 14px 24px; - gap: 20px; - flex-wrap: wrap; -} - -.header-branding { flex: 0 0 auto; } - -.logo-link { - text-decoration: none; - color: inherit; - display: flex; - align-items: center; - gap: 10px; - cursor: pointer; -} - -.logo { - margin: 0; - font-size: 22px; - font-weight: 700; - color: var(--primary-color); - line-height: 1.2; -} - -.logo-tagline { - margin: 0; - font-size: 12px; - color: var(--text-secondary); - font-weight: 400; -} - -.ai-banner-header { - display: flex; - align-items: center; - gap: 8px; - background: linear-gradient(135deg, #fef3e8 0%, #fff8f0 100%); - padding: 8px 16px; - border-radius: 8px; - font-size: 13px; - color: var(--primary-color); - font-weight: 500; - white-space: nowrap; - border: 1px solid #f5c89a; - cursor: pointer; - transition: var(--transition); -} - -.ai-banner-header:hover { - background: linear-gradient(135deg, #fde8cc 0%, #fef3e8 100%); - border-color: var(--accent-color); - box-shadow: var(--shadow-sm); -} - -.ai-icon { font-size: 16px; } - -.header-utilities { - flex: 0 0 auto; - display: flex; - gap: 6px; - align-items: center; -} - -.utility-button { - padding: 7px 11px; - border: 1px solid var(--border-color); - background: white; - border-radius: 6px; - cursor: pointer; - font-size: 13px; - font-weight: 500; - color: var(--text-primary); - transition: var(--transition); - min-width: 34px; - text-align: center; -} - -.utility-button:hover { background: var(--bg-light); border-color: var(--text-light); } -.utility-button:active { background: var(--primary-color); color: white; border-color: var(--primary-color); } - -/* ===== Navigation ===== */ -.main-nav { - background: var(--primary-color); - border-bottom: 3px solid var(--accent-color); - position: sticky; - top: 61px; - z-index: 99; -} - -.nav-content { - max-width: 1200px; - margin: 0 auto; - padding: 0 24px; - display: flex; -} - -.nav-link { - color: rgba(255,255,255,0.85); - text-decoration: none; - padding: 11px 20px; - display: flex; - align-items: center; - font-weight: 500; - font-size: 14px; - border-bottom: 3px solid transparent; - transition: var(--transition); - cursor: pointer; -} - -.nav-link:hover { background: rgba(255,255,255,0.1); color: white; } -.nav-link.active { border-bottom-color: var(--accent-color); background: rgba(255,255,255,0.12); color: white; } - -/* ===== Container ===== */ -.container { - max-width: 1200px; - margin: 0 auto; - padding: 24px; -} - -/* ===== Hero ===== */ -.hero-section { - background: linear-gradient(135deg, #1a5c38 0%, #2d7d4a 60%, #e07b39 100%); - color: white; - padding: 40px 32px; - border-radius: var(--radius); - margin-bottom: 28px; - text-align: center; -} - -.hero-title { - margin: 0 0 10px 0; - font-size: 30px; - font-weight: 700; - line-height: 1.3; -} - -.hero-subtitle { - margin: 0; - font-size: 17px; - opacity: 0.92; - line-height: 1.5; -} - -/* ===== Home Grid ===== */ -.home-grid { - display: grid; - grid-template-columns: 2fr 1fr; - gap: 24px; - align-items: start; -} - -.home-main { min-width: 0; } -.home-side { min-width: 0; } - -/* ===== Category Cards ===== */ -.category-list { - display: flex; - flex-direction: column; - gap: 10px; -} - -.category-card { - display: flex; - align-items: center; - gap: 16px; - padding: 18px 20px; - background: white; - border: 1px solid var(--border-color); - border-left: 4px solid var(--accent-color); - border-radius: var(--radius); - cursor: pointer; - transition: var(--transition); - font-size: 15px; - text-align: left; - width: 100%; -} - -.category-card:hover { - box-shadow: var(--shadow-md); - transform: translateY(-1px); - border-left-color: var(--primary-color); -} - -.category-card:active { transform: translateY(0); } - -.category-icon { font-size: 26px; flex-shrink: 0; width: 36px; text-align: center; } - -.category-card h3 { - margin: 0 0 4px 0; - font-size: 15px; - font-weight: 600; - color: var(--text-primary); -} - -.category-card p { - margin: 0; - font-size: 13px; - color: var(--text-secondary); - line-height: 1.4; -} - -/* ===== AI Guidance Section ===== */ -.ai-guidance-section { - background: linear-gradient(135deg, #fef3e8 0%, #fff8f0 100%); - border: 1px solid #f5c89a; - border-radius: var(--radius); - padding: 22px; - margin-top: 24px; -} - -.ai-guidance-section h3 { - margin: 0 0 5px 0; - font-size: 17px; - font-weight: 600; - color: var(--primary-color); -} - -.ai-guidance-subtitle { - margin: 0 0 14px 0; - font-size: 13px; - color: var(--text-secondary); -} - -.ai-input-group { display: flex; gap: 8px; margin-bottom: 10px; } - -.ai-input { - flex: 1; - padding: 10px 14px; - border: 1px solid #f5c89a; - border-radius: 8px; - font-size: 14px; - background: white; - transition: var(--transition); -} - -.ai-input:focus { - outline: none; - border-color: var(--accent-color); - box-shadow: 0 0 0 3px rgba(224,123,57,0.15); -} - -.ai-submit-btn { - padding: 10px 20px; - background: var(--accent-color); - color: white; - border: none; - border-radius: 8px; - font-weight: 600; - cursor: pointer; - transition: var(--transition); - font-size: 14px; - white-space: nowrap; -} - -.ai-submit-btn:hover { background: #c4662a; } -.ai-submit-btn:active { background: var(--primary-color); } - -.ai-reset-btn { - padding: 10px 16px; - background: white; - color: var(--text-secondary); - border: 1px solid var(--border-color); - border-radius: 8px; - font-weight: 500; - cursor: pointer; - transition: var(--transition); - font-size: 14px; - white-space: nowrap; -} - -.ai-reset-btn:hover { background: var(--bg-light); border-color: var(--text-light); color: var(--text-primary); } - -.card-source-link { - color: var(--accent-color); - text-decoration: none; - font-weight: 500; -} - -.card-source-link:hover { text-decoration: underline; } - -.detail-source-link { - display: inline-block; - margin-top: 8px; - color: white; - background: var(--accent-color); - padding: 7px 16px; - border-radius: 6px; - text-decoration: none; - font-weight: 600; - font-size: 14px; - transition: var(--transition); -} - -.detail-source-link:hover { background: #c4662a; } - -.ai-chips { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; } - -.ai-chip { - padding: 5px 12px; - background: white; - border: 1px solid #f5c89a; - border-radius: 20px; - cursor: pointer; - font-size: 13px; - transition: var(--transition); -} - -.ai-chip:hover { background: #fef3e8; } -.ai-chip.selected { background: var(--accent-color); color: white; border-color: var(--accent-color); } - -/* ===== AI Output ===== */ -.ai-output { - margin-top: 12px; -} - -.ai-loading { - display: flex; - align-items: center; - gap: 12px; - background: white; - padding: 14px 16px; - border-radius: 8px; - font-size: 14px; - color: var(--text-secondary); -} - -@keyframes spin { - to { transform: rotate(360deg); } -} - -.spinner { - width: 18px; - height: 18px; - border: 2px solid #fef3e8; - border-top-color: var(--accent-color); - border-radius: 50%; - animation: spin 0.8s linear infinite; - flex-shrink: 0; -} - -.ai-error { - color: var(--error-color); - font-size: 14px; - background: #fef2f2; - padding: 10px 14px; - border-radius: 8px; - border: 1px solid #fecaca; - margin: 0; -} - -/* ===== AI Response Card ===== */ -.ai-response-card { - background: white; - border-radius: 10px; - padding: 16px; - border: 1px solid #f5c89a; -} - -.ai-response-header { - display: flex; - align-items: flex-start; - gap: 10px; - margin-bottom: 12px; - padding-bottom: 10px; - border-bottom: 1px solid var(--border-color); -} - -.ai-response-icon { font-size: 20px; flex-shrink: 0; margin-top: 1px; } - -.ai-response-title { - margin: 0; - font-size: 16px; - font-weight: 600; - color: var(--primary-color); - line-height: 1.3; -} - -.ai-response-notes { - margin: 0 0 12px 0; - font-size: 13px; - color: var(--text-secondary); - font-style: italic; -} - -.ai-steps { display: flex; flex-direction: column; gap: 10px; } - -.ai-step { - display: flex; - gap: 12px; - align-items: flex-start; -} - -.ai-step-number { - width: 24px; - height: 24px; - border-radius: 50%; - background: var(--accent-color); - color: white; - font-size: 12px; - font-weight: 700; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - margin-top: 2px; -} - -.ai-step-body { flex: 1; } - -.ai-step-title { - font-size: 14px; - font-weight: 500; - color: var(--text-primary); - line-height: 1.4; -} - -.ai-step-why { - font-size: 13px; - color: var(--text-secondary); - margin-top: 3px; - line-height: 1.4; -} - -.ai-no-steps { - font-size: 14px; - color: var(--text-secondary); - margin: 0; -} - -.ai-citations { - margin-top: 12px; - padding-top: 10px; - border-top: 1px solid var(--border-color); - font-size: 13px; - color: var(--text-secondary); -} - -.ai-citations ul { - margin: 6px 0 0 0; - padding-left: 18px; -} - -.ai-citations li { margin: 3px 0; } - -/* ===== Latest Updates Sidebar ===== */ -.resource-panel { - background: white; - border: 1px solid var(--border-color); - border-radius: var(--radius); - padding: 18px; - box-shadow: var(--shadow-sm); - position: sticky; - top: 160px; -} - -.resource-panel h3 { - margin: 0 0 14px 0; - font-size: 15px; - font-weight: 600; - color: var(--text-primary); - padding-bottom: 10px; - border-bottom: 2px solid var(--accent-color); -} - -.news-list { display: flex; flex-direction: column; gap: 10px; } - -.news-item { - padding: 10px 12px; - background: var(--bg-lighter); - border-radius: 8px; - border-left: 3px solid var(--accent-color); - cursor: pointer; - transition: var(--transition); -} - -.news-item:hover { background: var(--bg-light); transform: translateX(3px); } - -.news-item h4 { - margin: 0 0 3px 0; - font-size: 13px; - font-weight: 600; - color: var(--text-primary); - line-height: 1.3; -} - -.news-item .news-date { font-size: 11px; color: var(--text-light); } - -/* ===== Filter Screen ===== */ -#filter-screen { - background: white; - border-radius: var(--radius); - padding: 24px; - border: 1px solid var(--border-color); - margin-top: 8px; -} - -.screen-header { - display: flex; - align-items: center; - gap: 14px; - margin-bottom: 16px; -} - -.screen-header h2 { margin: 0; font-size: 22px; font-weight: 700; } - -.back-button { - padding: 7px 14px; - background: white; - border: 1px solid var(--border-color); - border-radius: 8px; - cursor: pointer; - font-size: 14px; - transition: var(--transition); - color: var(--text-primary); - white-space: nowrap; -} - -.back-button:hover { background: var(--bg-light); border-color: var(--primary-color); color: var(--primary-color); } - -.screen-subtitle { color: var(--text-secondary); font-size: 14px; margin: 0 0 18px 0; } - -.filter-options { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 22px; } - -.filter-chip { - padding: 9px 16px; - background: white; - border: 2px solid var(--border-color); - border-radius: 24px; - cursor: pointer; - font-size: 14px; - font-weight: 500; - transition: var(--transition); - color: var(--text-primary); -} - -.filter-chip:hover { border-color: var(--accent-color); background: #fef3e8; color: var(--primary-color); } -.filter-chip.selected { background: var(--accent-color); color: white; border-color: var(--accent-color); } - -.primary-button { - padding: 11px 24px; - background: var(--primary-color); - color: white; - border: none; - border-radius: 8px; - font-weight: 600; - cursor: pointer; - font-size: 15px; - transition: var(--transition); -} - -.primary-button:hover { background: var(--primary-light); } - -/* ===== Results Screen ===== */ -.results-header { display: flex; align-items: center; gap: 14px; margin-bottom: 20px; } - -.results-list { display: flex; flex-direction: column; gap: 0; } - -/* ===== Resource Cards ===== */ -.resource-card { - background: white; - border: 1px solid var(--border-color); - border-left: 4px solid var(--accent-color); - border-radius: var(--radius); - padding: 16px 18px; - cursor: pointer; - transition: var(--transition); - margin-bottom: 12px; -} - -.resource-card:hover { - box-shadow: var(--shadow-md); - transform: translateY(-2px); - border-left-color: var(--primary-color); -} - -.resource-card:active { transform: translateY(0); } - -.card-top { - display: flex; - justify-content: space-between; - align-items: flex-start; - gap: 12px; - margin-bottom: 6px; -} - -.card-title { - margin: 0; - font-size: 15px; - font-weight: 600; - color: var(--primary-color); - line-height: 1.3; - flex: 1; -} - -.card-summary { - margin: 0 0 8px 0; - font-size: 13px; - color: var(--text-secondary); - line-height: 1.5; -} - -.card-phone { - margin: 0 0 6px 0; - font-size: 13px; -} - -.card-phone a { - color: var(--accent-color); - text-decoration: none; - font-weight: 500; -} - -.card-phone a:hover { text-decoration: underline; } - -.card-why { - margin: 4px 0; - font-size: 13px; - color: var(--text-secondary); - line-height: 1.4; -} - -.card-source { - margin: 6px 0 0 0; - font-size: 11px; - color: var(--text-light); -} - -.card-cta { - margin: 8px 0 0 0; - font-size: 12px; - font-weight: 600; - color: var(--accent-color); - letter-spacing: 0.2px; -} - -.empty-state { - color: var(--text-secondary); - font-size: 14px; - padding: 20px; - text-align: center; -} - -/* ===== Urgency Tags ===== */ -.urgency-tag { - display: inline-block; - padding: 3px 10px; - border-radius: 12px; - font-size: 11px; - font-weight: 600; - white-space: nowrap; - flex-shrink: 0; -} - -.urgency-emergency { background: #fef2f2; color: #991b1b; } -.urgency-time-limited { background: #fffbeb; color: #92400e; } -.urgency-standard { background: #f0fdf4; color: #166534; } - -/* ===== Page Headers ===== */ -.page-header { - margin-bottom: 20px; - padding-bottom: 16px; - border-bottom: 2px solid var(--border-color); -} - -.page-header h2 { - margin: 0 0 6px 0; - font-size: 22px; - font-weight: 700; - color: var(--primary-color); -} - -.page-description { - color: var(--text-secondary); - font-size: 14px; - line-height: 1.5; - margin: 0; -} - -/* ===== Category Group Headers ===== */ -.category-group-header { - margin: 22px 0 8px; - font-size: 14px; - font-weight: 600; - color: var(--primary-color); - text-transform: uppercase; - letter-spacing: 0.5px; - border-bottom: 1px solid var(--border-color); - padding-bottom: 6px; -} - -/* ===== Detail Screen ===== */ -#detail-screen { padding-top: 4px; } - -.detail-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - gap: 12px; - margin-bottom: 20px; - padding-bottom: 16px; - border-bottom: 2px solid var(--border-color); -} - -.detail-org { - margin: 0; - font-size: 22px; - font-weight: 700; - color: var(--primary-color); - flex: 1; - line-height: 1.3; -} - -.detail-section { - padding: 14px 0; - border-bottom: 1px solid var(--border-color); -} - -.detail-section:last-child { border-bottom: none; } - -.detail-label { - font-size: 11px; - font-weight: 700; - color: var(--text-light); - text-transform: uppercase; - letter-spacing: 0.6px; - margin-bottom: 5px; -} - -.detail-value { - font-size: 15px; - color: var(--text-primary); - line-height: 1.5; -} - -.detail-value a, .detail-phone-link { - color: var(--accent-color); - text-decoration: none; - font-weight: 500; -} - -.detail-value a:hover, .detail-phone-link:hover { text-decoration: underline; } - -/* ===== Seasonal Carousel ===== */ -#seasonal-carousel { - display: flex; - gap: 14px; - overflow-x: auto; - padding: 8px 0 16px 0; - scroll-snap-type: x mandatory; - -webkit-overflow-scrolling: touch; -} - -#seasonal-carousel::-webkit-scrollbar { height: 5px; } -#seasonal-carousel::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 4px; } - -.carousel-card { - flex-shrink: 0; - width: 75%; - scroll-snap-align: start; - border-radius: var(--radius); - overflow: hidden; - border: 1px solid var(--border-color); - background: white; - box-shadow: var(--shadow-sm); -} - -.carousel-card img { - width: 100%; - height: 420px; - object-fit: contain; - display: block; - background: #f9fafb; -} - -.carousel-caption { - padding: 10px 14px; - font-size: 13px; - color: var(--text-secondary); -} - -/* ===== Essentials Category Nav ===== */ -.essentials-category-nav { - display: flex; - flex-wrap: wrap; - gap: 8px; - align-items: center; - margin-bottom: 20px; - padding: 12px 16px; - background: var(--bg-light); - border-radius: 8px; - border: 1px solid var(--border-color); -} - -.essentials-nav-label { - font-size: 12px; - font-weight: 600; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.5px; - margin-right: 4px; -} - -.essentials-nav-link { - display: inline-block; - padding: 5px 12px; - background: white; - border: 1px solid var(--border-color); - border-radius: 20px; - font-size: 13px; - font-weight: 500; - color: var(--primary-color); - text-decoration: none; - transition: var(--transition); -} - -.essentials-nav-link:hover { - background: var(--accent-color); - color: white; - border-color: var(--accent-color); -} - -/* ===== Results Page AI Widget ===== */ -.results-ai-section { - background: linear-gradient(135deg, #fef3e8 0%, #fff8f0 100%); - border: 1px solid #f5c89a; - border-radius: var(--radius); - padding: 20px 22px; - margin-top: 28px; -} - -.results-ai-header { - display: flex; - align-items: center; - gap: 10px; - margin-bottom: 4px; -} - -.results-ai-header h4 { - margin: 0; - font-size: 15px; - font-weight: 600; - color: var(--primary-color); -} - -.results-ai-subtitle { - margin: 0 0 12px 0; - font-size: 13px; - color: var(--text-secondary); -} - -.results-ai-chips { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-bottom: 12px; -} - -.results-ai-chip { - padding: 6px 14px; - background: white; - border: 1px solid #f5c89a; - border-radius: 20px; - cursor: pointer; - font-size: 13px; - transition: var(--transition); - color: var(--text-primary); -} - -.results-ai-chip:hover { background: #fef3e8; border-color: var(--accent-color); } -.results-ai-chip.selected { background: var(--accent-color); color: white; border-color: var(--accent-color); } - -.results-ai-input-row { - display: flex; - gap: 8px; - margin-bottom: 10px; -} - -.results-ai-input-row .ai-input { flex: 1; } - -/* ===== High Contrast ===== */ -body.high-contrast { background-color: #000; color: #fff; } - -body.high-contrast .site-header, -body.high-contrast .resource-panel, -body.high-contrast .category-card, -body.high-contrast .ai-guidance-section, -body.high-contrast #filter-screen, -body.high-contrast .resource-card, -body.high-contrast .ai-response-card { - background-color: #000; - color: #fff; - border-color: #fff; -} - -body.high-contrast .main-nav { background-color: #111; border-color: #ff0; } -body.high-contrast .nav-link { color: #fff; } -body.high-contrast .nav-link.active { border-bottom-color: #ff0; } -body.high-contrast .category-card { border-left-color: #ff0; } -body.high-contrast .resource-card { border-left-color: #ff0; } -body.high-contrast .urgency-tag { background: #333; color: #ff0; } -body.high-contrast a, body.high-contrast .card-phone a, body.high-contrast .detail-value a { color: #ff0; } -body.high-contrast .utility-button, -body.high-contrast .filter-chip, -body.high-contrast .ai-chip, -body.high-contrast .back-button { background: #000; border-color: #fff; color: #fff; } -body.high-contrast .ai-input { background: #000; border-color: #fff; color: #fff; } -body.high-contrast .page-header, body.high-contrast .detail-header, body.high-contrast .detail-section { border-color: #555; } -body.high-contrast .detail-label { color: #aaa; } -body.high-contrast .card-cta { color: #ff0; } - -/* ===== Responsive ===== */ -@media (max-width: 768px) { - .header-content { flex-direction: column; gap: 10px; padding: 12px 16px; } - .home-grid { grid-template-columns: 1fr; } - .home-side { display: none; } - .hero-title { font-size: 24px; } - .hero-subtitle { font-size: 15px; } - .hero-section { padding: 28px 20px; } - .ai-banner-header { display: none; } - .ai-input-group { flex-direction: column; } - .container { padding: 16px; } - .carousel-card { width: 88%; } - .nav-content { overflow-x: auto; } - .nav-link { padding: 10px 14px; font-size: 13px; } - .card-top { flex-direction: column; gap: 6px; } - .detail-org { font-size: 18px; } - .main-nav { top: 52px; } -} - -/* ===== Print ===== */ -@media print { - .site-header, .main-nav, .back-button { display: none; } - .container { max-width: 100%; padding: 0; } -} diff --git a/backend/target/firststep-backend-0.1.0.jar b/backend/target/firststep-backend-0.1.0.jar deleted file mode 100644 index 123e7af..0000000 Binary files a/backend/target/firststep-backend-0.1.0.jar and /dev/null differ diff --git a/backend/target/firststep-backend-0.1.0.jar.original b/backend/target/firststep-backend-0.1.0.jar.original deleted file mode 100644 index 5e4407d..0000000 Binary files a/backend/target/firststep-backend-0.1.0.jar.original and /dev/null differ diff --git a/backend/target/maven-archiver/pom.properties b/backend/target/maven-archiver/pom.properties deleted file mode 100644 index 91a095e..0000000 --- a/backend/target/maven-archiver/pom.properties +++ /dev/null @@ -1,3 +0,0 @@ -artifactId=firststep-backend -groupId=org.firststep -version=0.1.0 diff --git a/backend/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/backend/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst deleted file mode 100644 index 3b9d1a0..0000000 --- a/backend/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst +++ /dev/null @@ -1,25 +0,0 @@ -org/firststep/backend/controller/DecisionController.class -org/firststep/backend/service/NewsService.class -org/firststep/backend/model/Resource$Website.class -org/firststep/backend/dto/DecisionResponse.class -org/firststep/backend/service/ResourceService$3.class -org/firststep/backend/service/DecisionAgentService.class -org/firststep/backend/service/OllamaService.class -org/firststep/backend/service/NewsService$1.class -org/firststep/backend/service/DecisionAgentService$ResourceScore.class -org/firststep/backend/FirstStepApplication.class -org/firststep/backend/controller/NewsController.class -org/firststep/backend/controller/ResourceController.class -org/firststep/backend/service/DecisionAgentService$NewsServiceLike.class -org/firststep/backend/model/NewsItem.class -org/firststep/backend/model/Resource$Phone.class -org/firststep/backend/service/ResourceService$2.class -org/firststep/backend/service/ResourceService$1.class -org/firststep/backend/dto/DecisionRequest.class -org/firststep/backend/service/DecisionAgentService$NewsScore.class -org/firststep/backend/service/ResourceService.class -org/firststep/backend/dto/DecisionStep.class -org/firststep/backend/service/DecisionAgentService$ResourceServiceLike.class -org/firststep/backend/dto/Citation.class -org/firststep/backend/model/Resource$Location.class -org/firststep/backend/model/Resource.class diff --git a/backend/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/backend/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst deleted file mode 100644 index 7ba7fc8..0000000 --- a/backend/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst +++ /dev/null @@ -1,16 +0,0 @@ -/Users/anitra/Projects/FirstStep/backend/src/main/java/org/firststep/backend/controller/DecisionController.java -/Users/anitra/Projects/FirstStep/backend/src/main/java/org/firststep/backend/service/ResourceService.java -/Users/anitra/Projects/FirstStep/backend/src/main/java/org/firststep/backend/service/OllamaService.java -/Users/anitra/Projects/FirstStep/backend/src/main/java/org/firststep/backend/dto/DecisionResponse.java -/Users/anitra/Projects/FirstStep/backend/src/main/java/org/firststep/backend/controller/NewsController.java -/Users/anitra/Projects/FirstStep/backend/src/main/java/org/firststep/backend/dto/DecisionRequest.java -/Users/anitra/Projects/FirstStep/backend/src/main/java/org/firststep/backend/model/Resource.java -/Users/anitra/Projects/FirstStep/backend/src/main/java/org/firststep/backend/dto/DecisionStep.java -/Users/anitra/Projects/FirstStep/backend/src/main/java/org/firststep/backend/service/NewsService.java -/Users/anitra/Projects/FirstStep/backend/src/main/java/org/firststep/backend/dto/Citation.java -/Users/anitra/Projects/FirstStep/backend/src/main/java/org/firststep/backend/service/RssFeedSource.java -/Users/anitra/Projects/FirstStep/backend/src/main/java/org/firststep/backend/FirstStepApplication.java -/Users/anitra/Projects/FirstStep/backend/src/main/java/org/firststep/backend/controller/ResourceController.java -/Users/anitra/Projects/FirstStep/backend/src/main/java/org/firststep/backend/service/DecisionAgentService.java -/Users/anitra/Projects/FirstStep/backend/src/main/java/org/firststep/backend/model/NewsItem.java -/Users/anitra/Projects/FirstStep/backend/src/main/java/org/firststep/backend/service/RssFeedService.java diff --git a/backend/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/createdFiles.lst b/backend/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/createdFiles.lst deleted file mode 100644 index e69de29..0000000 diff --git a/backend/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst b/backend/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst deleted file mode 100644 index a1268b5..0000000 --- a/backend/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst +++ /dev/null @@ -1 +0,0 @@ -/Users/anitra/Projects/FirstStep/backend/src/test/java/org/firststep/backend/controller/NewsControllerTest.java diff --git a/backend/target/surefire-reports/TEST-org.firststep.backend.controller.NewsControllerTest.xml b/backend/target/surefire-reports/TEST-org.firststep.backend.controller.NewsControllerTest.xml deleted file mode 100644 index cc3260d..0000000 --- a/backend/target/surefire-reports/TEST-org.firststep.backend.controller.NewsControllerTest.xml +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/backend/target/surefire-reports/org.firststep.backend.controller.NewsControllerTest.txt b/backend/target/surefire-reports/org.firststep.backend.controller.NewsControllerTest.txt deleted file mode 100644 index 643c5f9..0000000 --- a/backend/target/surefire-reports/org.firststep.backend.controller.NewsControllerTest.txt +++ /dev/null @@ -1,4 +0,0 @@ -------------------------------------------------------------------------------- -Test set: org.firststep.backend.controller.NewsControllerTest -------------------------------------------------------------------------------- -Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.369 s - in org.firststep.backend.controller.NewsControllerTest diff --git a/backend/target/test-classes/org/firststep/backend/controller/NewsControllerTest$TestConfig.class b/backend/target/test-classes/org/firststep/backend/controller/NewsControllerTest$TestConfig.class deleted file mode 100644 index 0a64ebe..0000000 Binary files a/backend/target/test-classes/org/firststep/backend/controller/NewsControllerTest$TestConfig.class and /dev/null differ diff --git a/backend/target/test-classes/org/firststep/backend/controller/NewsControllerTest.class b/backend/target/test-classes/org/firststep/backend/controller/NewsControllerTest.class deleted file mode 100644 index 4bcf55f..0000000 Binary files a/backend/target/test-classes/org/firststep/backend/controller/NewsControllerTest.class and /dev/null differ diff --git a/references/RssFeedService_annotated.java b/references/RssFeedService_annotated.java new file mode 100644 index 0000000..7489e90 --- /dev/null +++ b/references/RssFeedService_annotated.java @@ -0,0 +1,379 @@ +package org.firststep.backend.service; + +// ============================================================================= +// WHAT THIS CLASS DOES +// ============================================================================= +// RssFeedService fetches one or more RSS feeds on a schedule, converts each +// entry into a NewsItem, classifies it by civic topic (housing, healthcare, +// food, etc.), and holds the result in memory for the NewsController to serve +// at GET /api/news/rss. +// +// It implements RssFeedSource so it can be swapped with a fake in tests +// without needing Mockito on concrete classes. +// ============================================================================= + +import com.rometools.rome.feed.synd.*; +import com.rometools.rome.io.SyndFeedInput; +import com.rometools.rome.io.XmlReader; +import org.firststep.backend.model.NewsItem; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.net.URL; +import java.net.URLConnection; +import java.text.SimpleDateFormat; +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class RssFeedService implements RssFeedSource { + + private static final Logger log = LoggerFactory.getLogger(RssFeedService.class); + + // WHY: Comma-separated URL list from application.properties so URLs can be + // changed or added without recompiling. + // Configured as: news.rss.urls=https://legis.delaware.gov/rss/RssFeeds/GovernorSignedLegislation + @Value("${news.rss.urls:}") + private String rssFeedUrls; + + // WHY: volatile ensures the list is visible across threads when @Scheduled + // writes it and the request thread reads it concurrently. + private volatile List rssItems = List.of(); + + private static final SimpleDateFormat DATE_FMT = new SimpleDateFormat("yyyy-MM-dd"); + + // ============================================================================= + // SCHEDULED FETCH + // ============================================================================= + // WHY initialDelayString vs initialDelay: The property-driven form allows the + // delay to be tuned in tests and deployment without recompiling. Default 500ms + // is fast enough that items appear before the user first loads the page. + // WHY fixedDelay (not fixedRate): fixedDelay waits after the previous run + // completes, avoiding overlap if a fetch takes longer than the interval. + @Scheduled( + fixedDelayString = "${news.rss.refresh-interval:3600000}", + initialDelayString = "${news.rss.initial-delay:500}" + ) + public void fetchFeeds() { + if (rssFeedUrls == null || rssFeedUrls.isBlank()) { + log.warn("RSS: No feed URLs configured"); + return; + } + + List collected = new ArrayList<>(); + + for (String rawUrl : rssFeedUrls.split(",")) { + String url = rawUrl.trim(); + if (url.isEmpty()) continue; + + try { + SyndFeed feed = loadFeed(url); + if (feed == null) continue; + + for (SyndEntry entry : feed.getEntries()) { + NewsItem item = convertEntry(feed, entry); + collected.add(item); + } + + log.info("RSS: Loaded {} entries from {}", feed.getEntries().size(), url); + + } catch (Exception ex) { + log.error("RSS: Failed to load {} → {}", url, ex.getMessage()); + } + } + + // WHY only replace when non-empty: a transient network failure during a + // refresh should not wipe out the last good result. + if (!collected.isEmpty()) { + rssItems = List.copyOf(collected); + } + } + + public List getRssItems() { + return rssItems; + } + + // ------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------ + + // WHY setAllowDoctypes(true): Delaware's feed includes a DOCTYPE declaration + // which JAXP blocks by default as a security measure. We allow it here + // because this is a trusted government feed. + private SyndFeed loadFeed(String url) { + try { + URL u = new URL(url); + URLConnection conn = u.openConnection(); + conn.setRequestProperty("User-Agent", "Mozilla/5.0 (RSS Reader)"); + conn.setConnectTimeout(8000); + conn.setReadTimeout(8000); + + SyndFeedInput input = new SyndFeedInput(); + input.setAllowDoctypes(true); + return input.build(new XmlReader(conn)); + + } catch (Exception e) { + log.error("RSS: Error reading {} → {}", url, e.getMessage()); + return null; + } + } + + // ============================================================================= + // ENTRY → NewsItem CONVERSION + // ============================================================================= + // HOW IT WORKS: + // 1. Map raw RSS fields (title, description, link, date) to NewsItem fields. + // 2. Run keyword classification to assign civic category tags and a generic + // "why it matters" sentence. + // 3. Try to extract a "RELATING TO …" clause from the description. Delaware + // legislation descriptions always begin with the full act title in ALL CAPS, + // e.g. "AN ACT TO AMEND TITLE 1 … RELATING TO PUERTO RICO DAY." If found, + // this clause (converted to Sentence Case) replaces both the bill-number + // headline and the generic why-it-matters text, giving users a readable, + // specific description of the law. + private NewsItem convertEntry(SyndFeed feed, SyndEntry entry) { + NewsItem item = new NewsItem(); + + item.id = "rss-" + UUID.randomUUID(); + item.headline = safe(entry.getTitle()); // bill number e.g. "HB 311" — overridden below if RELATING TO found + item.summary = extractSummary(entry); + item.body = item.summary; + + item.sourceName = feed.getTitle() != null ? feed.getTitle() : "RSS Feed"; + item.sourceUrl = entry.getLink(); + + Date published = entry.getPublishedDate() != null + ? entry.getPublishedDate() + : entry.getUpdatedDate() != null + ? entry.getUpdatedDate() + : new Date(); + + item.published = DATE_FMT.format(published); + + item.active = true; + item.type = "legislation"; + item.urgency = "standard"; + + // Step 2: keyword classification + String text = (item.headline + " " + item.summary).toLowerCase(); + Classification cls = classifyLegislation(text); + item.categoryTags = cls.categoryTags; + item.resourceTags = cls.resourceTags; + item.whyItMatters = cls.whyItMatters; + + // Step 3: extract "RELATING TO …" clause for a more readable headline/why + String relatingTo = extractRelatingTo(item.summary); + if (relatingTo != null) { + item.headline = relatingTo; + item.whyItMatters = relatingTo; + } + + return item; + } + + // ============================================================================= + // RELATING TO EXTRACTION + // ============================================================================= + // WHY: Delaware legislation descriptions always contain a formal title in ALL + // CAPS like "AN ACT TO AMEND … RELATING TO PAID LEAVE." The "RELATING TO" + // clause is the most human-readable part. We extract it and convert to + // Sentence Case so the UI can show "Relating to paid leave." instead of a + // bill number or a generic category sentence. + // + // ALGORITHM — three terminators, earliest wins: + // 1. First "." after "RELATING TO" — normal case where act title ends with a period. + // 2. "This " boundary — Delaware descriptions follow the act title with + // "This Act…", "This Senate…", etc. When there is no period before this + // phrase, stop just before it and append a period. + // 3. 120-character cap — hard safety net, truncates at last word boundary. + // Returns null if "RELATING TO" is not found or nothing remains after trimming. + private static final int RELATING_TO_MAX_CHARS = 120; + + private static String extractRelatingTo(String summary) { + if (summary == null) return null; + String upper = summary.toUpperCase(); + int start = upper.indexOf("RELATING TO"); + if (start < 0) return null; + + int end = Integer.MAX_VALUE; + + // Rule 1: first period after "RELATING TO" (include the period) + int periodIdx = summary.indexOf('.', start); + if (periodIdx >= 0) end = Math.min(end, periodIdx + 1); + + // Rule 2: "This " boundary — stop before it + java.util.regex.Matcher m = java.util.regex.Pattern + .compile("\\bThis\\s+[A-Z]", java.util.regex.Pattern.CASE_INSENSITIVE) + .matcher(summary); + while (m.find()) { + if (m.start() > start) { + int boundary = m.start(); + while (boundary > start && Character.isWhitespace(summary.charAt(boundary - 1))) { + boundary--; + } + end = Math.min(end, boundary); + break; + } + } + + if (end == Integer.MAX_VALUE || end <= start) return null; + + String raw = summary.substring(start, end).trim(); + + // Rule 3: character cap — truncate at last word boundary + if (raw.length() > RELATING_TO_MAX_CHARS) { + raw = raw.substring(0, RELATING_TO_MAX_CHARS); + int lastSpace = raw.lastIndexOf(' '); + if (lastSpace > 0) raw = raw.substring(0, lastSpace); + } + + if (raw.isEmpty()) return null; + + if (!raw.endsWith(".")) raw = raw + "."; + + return Character.toUpperCase(raw.charAt(0)) + raw.substring(1).toLowerCase(); + } + + // ============================================================================= + // CLASSIFICATION + // ============================================================================= + // WHY LinkedHashMap: insertion order matters — the first matched tag determines + // which "why it matters" sentence is used when multiple tags match. + // + // HOW IT WORKS: Each entry's lowercase headline+summary is tested against + // keyword arrays. Any matching bucket adds its display tag to the result list. + // If no bucket matches, a generic "Delaware Legislation" tag and fallback + // sentence are returned. + private static final class Classification { + List categoryTags; + List resourceTags; + String whyItMatters; + Classification(List categoryTags, List resourceTags, String whyItMatters) { + this.categoryTags = categoryTags; + this.resourceTags = resourceTags; + this.whyItMatters = whyItMatters; + } + } + + private static final Map TAG_KEYWORDS = new LinkedHashMap<>(); + static { + TAG_KEYWORDS.put("housing", new String[]{"housing", "rent", "landlord", "tenant", "evict", + "mortgage", "residential", "manufactured home", + "affordable rental", "shelter"}); + TAG_KEYWORDS.put("healthcare", new String[]{"health", "medical", "medicaid", "medicare", + "hospital", "clinic", "mental health", "prescription", + "nursing", "patient", "wellness", "behavioral health", + "opioid", "drug", "therapy", "physician", "care", + "insurance", "vaccination", "public health", + "drinking water", "long-term care", "school-based health"}); + TAG_KEYWORDS.put("food", new String[]{"food", "nutrition", "snap", "hunger", "grocery", + "meal", "wic", "restaurant meals", "dietitian", + "farm", "agriculture"}); + TAG_KEYWORDS.put("employment", new String[]{"employ", "worker", "wage", "labor", "job", + "workplace", "paid leave", "unemployment", + "workforce", "occupational", "salary", "licensure"}); + TAG_KEYWORDS.put("utilities", new String[]{"utility", "utilities", "electric", "energy", + "net meter", "solar", "water system"}); + TAG_KEYWORDS.put("disability", new String[]{"disability", "disabilities", "accessible", "accessibility", + "accommodation", "developmental disability", + "rehabilitation", "hearing", "blue envelope"}); + TAG_KEYWORDS.put("benefits", new String[]{"benefit", "assistance", "subsidy", "aid", + "social service", "low-income", "poverty", + "state employee benefit", "child care", + "school-based", "voucher"}); + TAG_KEYWORDS.put("legal", new String[]{"court", "justice", "civil right", "equal accommodation", + "protection", "eviction", "trafficking", + "stalking", "criminal", "juvenile"}); + } + + private static final Map TAG_WHY = new LinkedHashMap<>(); + static { + TAG_WHY.put("housing", "This new law may affect your rights as a renter, homeowner, or manufactured-home resident in Delaware."); + TAG_WHY.put("healthcare", "This new law may change what health services or coverage are available to you or your family."); + TAG_WHY.put("food", "This new law may affect food assistance programs or nutrition services in your community."); + TAG_WHY.put("employment", "This new law may change your rights or benefits at work, including wages, leave, or licensing."); + TAG_WHY.put("utilities", "This new law may affect your electric, water, or energy bills."); + TAG_WHY.put("disability", "This new law may expand services or protections for people with disabilities."); + TAG_WHY.put("benefits", "This new law may change assistance programs or benefits available to low-income Delawareans."); + TAG_WHY.put("legal", "This new law may affect your legal rights or access to the courts."); + } + + private static Classification classifyLegislation(String text) { + List matched = new ArrayList<>(); + for (Map.Entry entry : TAG_KEYWORDS.entrySet()) { + for (String kw : entry.getValue()) { + if (text.contains(kw)) { + matched.add(entry.getKey()); + break; + } + } + } + + if (matched.isEmpty()) { + return new Classification( + List.of("Delaware Legislation"), + List.of(), + "Stay informed about new laws signed by the Governor of Delaware." + ); + } + + List categoryTags = matched.stream() + .map(t -> Character.toUpperCase(t.charAt(0)) + t.substring(1)) + .collect(Collectors.toList()); + + List resourceTags = new ArrayList<>(matched); + + String why = TAG_WHY.get(matched.get(0)); + + return new Classification(categoryTags, resourceTags, why); + } + + // ============================================================================= + // SUMMARY EXTRACTION + // ============================================================================= + // WHY three sources: RSS feeds vary — most use , some use + // , and older feeds may use media extensions. Trying in order + // handles all three without configuration. + private String extractSummary(SyndEntry entry) { + // 1. Standard + if (entry.getDescription() != null && entry.getDescription().getValue() != null) { + return stripHtml(entry.getDescription().getValue()); + } + + // 2. + if (entry.getContents() != null && !entry.getContents().isEmpty()) { + SyndContent content = entry.getContents().get(0); + if (content != null && content.getValue() != null) { + return stripHtml(content.getValue()); + } + } + + // 3. Media module fallback + for (SyndContent c : entry.getContents()) { + if (c != null && c.getValue() != null) { + return stripHtml(c.getValue()); + } + } + + return ""; + } + + private static String safe(String s) { + return s == null ? "" : s.trim(); + } + + private static String stripHtml(String html) { + if (html == null) return ""; + return html + .replaceAll("<[^>]+>", " ") + .replaceAll(" ", " ") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("\\s+", " ") + .trim(); + } +} diff --git a/references/architecture.md b/references/architecture.md deleted file mode 100644 index e69de29..0000000 diff --git a/references/decisions.md b/references/decisions.md index be40734..ca6037f 100644 --- a/references/decisions.md +++ b/references/decisions.md @@ -11,4 +11,23 @@ Examples: - Domestic violence shelter Conclusion: -A filter stage is necessary before showing results. \ No newline at end of file +A filter stage is necessary before showing results. + +# Decision 004 + +Two filesystem reads used relative paths hardcoded to the repo root: +- ResourceService loaded `app/data/resources.json` +- ResourceController listed `backend/src/main/resources/static/images/seasonal` + +This tied the app to being launched from the repo root and blocked deployment +from any other working directory (e.g. a container). + +Conclusion: +Made both paths configurable via `@Value` properties, keeping the original +relative paths as defaults so existing behavior is unchanged: +- `app.data.dir` (default `app/data`) +- `app.seasonal.images.dir` (default `backend/src/main/resources/static/images/seasonal`) + +A different working directory now overrides these via env/properties without +code changes. Chose `@Value` over `@ConfigurationProperties` to match the +existing style (OllamaService already injects `ollama.api.url` via `@Value`). \ No newline at end of file