diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..7d05e99
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,10 @@
+# 默认忽略的文件
+/shelf/
+/workspace.xml
+# 基于编辑器的 HTTP 客户端请求
+/httpRequests/
+# 依赖于环境的 Maven 主目录路径
+/mavenHomeManager.xml
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/fred-api.iml b/.idea/fred-api.iml
new file mode 100644
index 0000000..25ed3f6
--- /dev/null
+++ b/.idea/fred-api.iml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..1945ce5
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..6116095
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..35eb1dd
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/handlers/fred.go b/handlers/fred.go
index 9416b50..7da3b10 100644
--- a/handlers/fred.go
+++ b/handlers/fred.go
@@ -43,25 +43,141 @@ func (h *FREDHandler) buildURL(endpoint string, params map[string]string) string
return u.String()
}
-func (h *FREDHandler) fetchData(url string, target interface{}) error {
+func (h *FREDHandler) fetchData(target interface{}, c *gin.Context, endpoint string, params map[string]string) {
+ url := h.buildURL(endpoint, params)
+
resp, err := http.Get(url)
if err != nil {
- return err
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ ErrorCode: "NetworkError",
+ ErrorMessage: err.Error(),
+ })
+ return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
- return err
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ ErrorCode: "ReadError",
+ ErrorMessage: err.Error(),
+ })
+ return
}
if resp.StatusCode != http.StatusOK {
var errResp models.ErrorResponse
json.Unmarshal(body, &errResp)
- return fmt.Errorf("API error: %s - %s", errResp.ErrorCode, errResp.ErrorMessage)
+ c.JSON(http.StatusBadGateway, models.ErrorResponse{
+ ErrorCode: "APIError",
+ ErrorMessage: fmt.Sprintf("Status: %d, Error: %s - %s", resp.StatusCode, errResp.ErrorCode, errResp.ErrorMessage),
+ })
+ return
}
- return json.Unmarshal(body, target)
+ if err := json.Unmarshal(body, target); err != nil {
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ ErrorCode: "ParseError",
+ ErrorMessage: err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, target)
+}
+
+func (h *FREDHandler) HealthCheck(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{
+ "status": "healthy",
+ "service": "FRED API",
+ })
+}
+
+func (h *FREDHandler) GetCategory(c *gin.Context) {
+ categoryID := c.Query("category_id")
+ if categoryID == "" {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ ErrorCode: "BadRequest",
+ ErrorMessage: "category_id is required",
+ })
+ return
+ }
+ h.fetchData(&models.CategoryResponse{}, c, "/category", map[string]string{"category_id": categoryID})
+}
+
+func (h *FREDHandler) GetCategoryChildren(c *gin.Context) {
+ categoryID := c.Query("category_id")
+ if categoryID == "" {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ ErrorCode: "BadRequest",
+ ErrorMessage: "category_id is required",
+ })
+ return
+ }
+ params := map[string]string{"category_id": categoryID}
+ h.fetchData(&models.CategoriesResponse{}, c, "/category/children", params)
+}
+
+func (h *FREDHandler) GetCategoryRelated(c *gin.Context) {
+ categoryID := c.Query("category_id")
+ if categoryID == "" {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ ErrorCode: "BadRequest",
+ ErrorMessage: "category_id is required",
+ })
+ return
+ }
+ h.fetchData(&models.RelatedTagsResponse{}, c, "/category/related", map[string]string{"category_id": categoryID})
+}
+
+func (h *FREDHandler) GetCategorySeries(c *gin.Context) {
+ categoryID := c.Query("category_id")
+ if categoryID == "" {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ ErrorCode: "BadRequest",
+ ErrorMessage: "category_id is required",
+ })
+ return
+ }
+ params := map[string]string{"category_id": categoryID}
+ h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end", "limit", "offset", "order_by", "sort_order"})
+ h.fetchData(&models.CategorySeriesResponse{}, c, "/category/series", params)
+}
+
+func (h *FREDHandler) GetCategoryTags(c *gin.Context) {
+ categoryID := c.Query("category_id")
+ if categoryID == "" {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ ErrorCode: "BadRequest",
+ ErrorMessage: "category_id is required",
+ })
+ return
+ }
+ params := map[string]string{"category_id": categoryID}
+ h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end", "limit", "offset", "order_by", "sort_order"})
+ h.fetchData(&models.CategoryTagsResponse{}, c, "/category/tags", params)
+}
+
+func (h *FREDHandler) GetCategoryRelatedTags(c *gin.Context) {
+ categoryID := c.Query("category_id")
+ if categoryID == "" {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ ErrorCode: "BadRequest",
+ ErrorMessage: "category_id is required",
+ })
+ return
+ }
+ tagNames := c.Query("tag_names")
+ if tagNames == "" {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ ErrorCode: "BadRequest",
+ ErrorMessage: "tag_names is required",
+ })
+ return
+ }
+ params := map[string]string{"category_id": categoryID, "tag_names": tagNames}
+ h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end", "limit", "offset", "order_by", "sort_order"})
+ h.fetchData(&models.RelatedTagsResponse{}, c, "/category/related_tags", params)
}
func (h *FREDHandler) GetSeries(c *gin.Context) {
@@ -73,30 +189,23 @@ func (h *FREDHandler) GetSeries(c *gin.Context) {
})
return
}
+ params := map[string]string{"series_id": seriesID}
+ h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end"})
+ h.fetchData(&models.SeriesResponse{}, c, "/series", params)
+}
- params := map[string]string{
- "series_id": seriesID,
- }
-
- if realtimeStart := c.Query("realtime_start"); realtimeStart != "" {
- params["realtime_start"] = realtimeStart
- }
- if realtimeEnd := c.Query("realtime_end"); realtimeEnd != "" {
- params["realtime_end"] = realtimeEnd
- }
-
- url := h.buildURL("/series", params)
-
- var response models.SeriesListResponse
- if err := h.fetchData(url, &response); err != nil {
- c.JSON(http.StatusInternalServerError, models.ErrorResponse{
- ErrorCode: "InternalError",
- ErrorMessage: err.Error(),
+func (h *FREDHandler) GetSeriesCategories(c *gin.Context) {
+ seriesID := c.Query("series_id")
+ if seriesID == "" {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ ErrorCode: "BadRequest",
+ ErrorMessage: "series_id is required",
})
return
}
-
- c.JSON(http.StatusOK, response)
+ params := map[string]string{"series_id": seriesID}
+ h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end"})
+ h.fetchData(&models.SeriesCategoriesResponse{}, c, "/series/categories", params)
}
func (h *FREDHandler) GetObservations(c *gin.Context) {
@@ -108,35 +217,27 @@ func (h *FREDHandler) GetObservations(c *gin.Context) {
})
return
}
-
- params := map[string]string{
- "series_id": seriesID,
- }
-
- optionalParams := []string{
+ params := map[string]string{"series_id": seriesID}
+ h.addOptionalParams(c, params, []string{
"realtime_start", "realtime_end", "limit", "offset", "sort_order",
"observation_start", "observation_end", "units", "frequency",
"aggregation_method", "output_type", "vintage_dates",
- }
+ })
+ h.fetchData(&models.ObservationsResponse{}, c, "/series/observations", params)
+}
- for _, param := range optionalParams {
- if v := c.Query(param); v != "" {
- params[param] = v
- }
- }
-
- url := h.buildURL("/series/observations", params)
-
- var response models.ObservationsResponse
- if err := h.fetchData(url, &response); err != nil {
- c.JSON(http.StatusInternalServerError, models.ErrorResponse{
- ErrorCode: "InternalError",
- ErrorMessage: err.Error(),
+func (h *FREDHandler) GetSeriesRelease(c *gin.Context) {
+ seriesID := c.Query("series_id")
+ if seriesID == "" {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ ErrorCode: "BadRequest",
+ ErrorMessage: "series_id is required",
})
return
}
-
- c.JSON(http.StatusOK, response)
+ params := map[string]string{"series_id": seriesID}
+ h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end"})
+ h.fetchData(&models.ReleaseResponse{}, c, "/series/release", params)
}
func (h *FREDHandler) SearchSeries(c *gin.Context) {
@@ -148,90 +249,135 @@ func (h *FREDHandler) SearchSeries(c *gin.Context) {
})
return
}
-
- params := map[string]string{
- "search_text": searchText,
- }
-
- optionalParams := []string{
+ params := map[string]string{"search_text": searchText}
+ h.addOptionalParams(c, params, []string{
"realtime_start", "realtime_end", "limit", "offset", "sort_order",
- "observation_start", "observation_end", "units", "frequency",
- "aggregation_method", "output_type",
- }
+ "order_by", "filter", "" +
+ "" +
+ "" +
+ "" +
+ "" +
+ "",
+ })
+ h.fetchData(&models.SearchResponse{}, c, "/series/search", params)
+}
- for _, param := range optionalParams {
- if v := c.Query(param); v != "" {
- params[param] = v
- }
- }
-
- url := h.buildURL("/series/search", params)
-
- var response models.SearchResponse
- if err := h.fetchData(url, &response); err != nil {
- c.JSON(http.StatusInternalServerError, models.ErrorResponse{
- ErrorCode: "InternalServerError",
- ErrorMessage: err.Error(),
+func (h *FREDHandler) SearchSeriesTags(c *gin.Context) {
+ searchText := c.Query("search_text")
+ if searchText == "" {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ ErrorCode: "BadRequest",
+ ErrorMessage: "search_text is required",
})
return
}
+ params := map[string]string{"search_text": searchText}
+ h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end", "limit", "offset", "order_by", "sort_order"})
+ h.fetchData(&models.TagsResponse{}, c, "/series/search/tags", params)
+}
- c.JSON(http.StatusOK, response)
+func (h *FREDHandler) SearchSeriesRelatedTags(c *gin.Context) {
+ searchText := c.Query("search_text")
+ if searchText == "" {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ ErrorCode: "BadRequest",
+ ErrorMessage: "search_text is required",
+ })
+ return
+ }
+ tagNames := c.Query("tag_names")
+ if tagNames == "" {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ ErrorCode: "BadRequest",
+ ErrorMessage: "tag_names is required",
+ })
+ return
+ }
+ params := map[string]string{"search_text": searchText, "tag_names": tagNames}
+ h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end", "limit", "offset", "order_by", "sort_order"})
+ h.fetchData(&models.RelatedTagsResponse{}, c, "/series/search/related_tags", params)
+}
+
+func (h *FREDHandler) GetSeriesTags(c *gin.Context) {
+ seriesID := c.Query("series_id")
+ if seriesID == "" {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ ErrorCode: "BadRequest",
+ ErrorMessage: "series_id is required",
+ })
+ return
+ }
+ params := map[string]string{"series_id": seriesID}
+ h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end", "limit", "offset", "order_by", "sort_order"})
+ h.fetchData(&models.SeriesTagsResponse{}, c, "/series/tags", params)
+}
+
+func (h *FREDHandler) GetSeriesUpdates(c *gin.Context) {
+ params := map[string]string{}
+ h.addOptionalParams(c, params, []string{
+ "realtime_start", "realtime_end", "limit", "offset", "sort_order",
+ "filter", "observation_start", "observation_end",
+ })
+ h.fetchData(&models.SeriesUpdatesResponse{}, c, "/series/updates", params)
+}
+
+func (h *FREDHandler) GetSeriesVintageDates(c *gin.Context) {
+ seriesID := c.Query("series_id")
+ if seriesID == "" {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ ErrorCode: "BadRequest",
+ ErrorMessage: "series_id is required",
+ })
+ return
+ }
+ params := map[string]string{"series_id": seriesID}
+ h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end"})
+ h.fetchData(&models.VintageDatesResponse{}, c, "/series/vintagedates", params)
}
func (h *FREDHandler) GetReleases(c *gin.Context) {
params := map[string]string{}
-
- optionalParams := []string{
- "realtime_start", "realtime_end", "limit", "offset", "sort_order",
- }
-
- for _, param := range optionalParams {
- if v := c.Query(param); v != "" {
- params[param] = v
- }
- }
-
- url := h.buildURL("/releases", params)
-
- var response models.ReleasesResponse
- if err := h.fetchData(url, &response); err != nil {
- c.JSON(http.StatusInternalServerError, models.ErrorResponse{
- ErrorCode: "InternalServerError",
- ErrorMessage: err.Error(),
- })
- return
- }
-
- c.JSON(http.StatusOK, response)
+ h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end", "limit", "offset", "sort_order"})
+ h.fetchData(&models.ReleasesResponse{}, c, "/releases", params)
}
func (h *FREDHandler) GetReleaseDates(c *gin.Context) {
params := map[string]string{}
-
- optionalParams := []string{
+ h.addOptionalParams(c, params, []string{
"realtime_start", "realtime_end", "limit", "offset", "sort_order",
"release_id", "date", "start_date", "end_date",
- }
+ })
+ h.fetchData(&models.ReleaseDatesResponse{}, c, "/releases/dates", params)
+}
- for _, param := range optionalParams {
- if v := c.Query(param); v != "" {
- params[param] = v
- }
- }
-
- url := h.buildURL("/releases/dates", params)
-
- var response models.ReleaseDatesResponse
- if err := h.fetchData(url, &response); err != nil {
- c.JSON(http.StatusInternalServerError, models.ErrorResponse{
- ErrorCode: "InternalServerError",
- ErrorMessage: err.Error(),
+func (h *FREDHandler) GetRelease(c *gin.Context) {
+ releaseIDStr := c.Query("release_id")
+ if releaseIDStr == "" {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ ErrorCode: "BadRequest",
+ ErrorMessage: "release_id is required",
})
return
}
+ releaseID, _ := strconv.Atoi(releaseIDStr)
+ params := map[string]string{"release_id": strconv.Itoa(releaseID)}
+ h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end"})
+ h.fetchData(&models.ReleaseResponse{}, c, "/release", params)
+}
- c.JSON(http.StatusOK, response)
+func (h *FREDHandler) GetReleaseDates2(c *gin.Context) {
+ releaseIDStr := c.Query("release_id")
+ if releaseIDStr == "" {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ ErrorCode: "BadRequest",
+ ErrorMessage: "release_id is required",
+ })
+ return
+ }
+ releaseID, _ := strconv.Atoi(releaseIDStr)
+ params := map[string]string{"release_id": strconv.Itoa(releaseID)}
+ h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end", "limit", "offset", "sort_order", "date", "start_date", "end_date"})
+ h.fetchData(&models.ReleaseDatesResponse{}, c, "/release/dates", params)
}
func (h *FREDHandler) GetReleaseSeries(c *gin.Context) {
@@ -243,49 +389,171 @@ func (h *FREDHandler) GetReleaseSeries(c *gin.Context) {
})
return
}
+ releaseID, _ := strconv.Atoi(releaseIDStr)
+ params := map[string]string{"release_id": strconv.Itoa(releaseID)}
+ h.addOptionalParams(c, params, []string{
+ "realtime_start", "realtime_end", "limit", "offset", "sort_order",
+ "order_by", "filter", "observation_start", "observation_end", "units", "frequency",
+ "aggregation_method", "output_type",
+ })
+ h.fetchData(&models.SearchResponse{}, c, "/release/series", params)
+}
- releaseID, err := strconv.Atoi(releaseIDStr)
- if err != nil {
+func (h *FREDHandler) GetReleaseSources(c *gin.Context) {
+ releaseIDStr := c.Query("release_id")
+ if releaseIDStr == "" {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
ErrorCode: "BadRequest",
- ErrorMessage: "release_id must be a number",
+ ErrorMessage: "release_id is required",
})
return
}
+ releaseID, _ := strconv.Atoi(releaseIDStr)
+ params := map[string]string{"release_id": strconv.Itoa(releaseID)}
+ h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end"})
+ h.fetchData(&models.ReleaseSourcesResponse{}, c, "/release/sources", params)
+}
- params := map[string]string{
- "release_id": strings.TrimSpace(strings.Replace(strconv.Itoa(releaseID), " ", "", -1)),
+func (h *FREDHandler) GetReleaseTags(c *gin.Context) {
+ releaseIDStr := c.Query("release_id")
+ if releaseIDStr == "" {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ ErrorCode: "BadRequest",
+ ErrorMessage: "release_id is required",
+ })
+ return
}
+ releaseID, _ := strconv.Atoi(releaseIDStr)
+ params := map[string]string{"release_id": strconv.Itoa(releaseID)}
+ h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end", "limit", "offset", "order_by", "sort_order"})
+ h.fetchData(&models.ReleaseTagsResponse{}, c, "/release/tags", params)
+}
- optionalParams := []string{
- "realtime_start", "realtime_end", "limit", "offset", "sort_order",
- "observation_start", "observation_end", "units", "frequency",
- "aggregation_method", "output_type",
+func (h *FREDHandler) GetReleaseRelatedTags(c *gin.Context) {
+ releaseIDStr := c.Query("release_id")
+ if releaseIDStr == "" {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ ErrorCode: "BadRequest",
+ ErrorMessage: "release_id is required",
+ })
+ return
}
+ tagNames := c.Query("tag_names")
+ if tagNames == "" {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ ErrorCode: "BadRequest",
+ ErrorMessage: "tag_names is required",
+ })
+ return
+ }
+ releaseID, _ := strconv.Atoi(releaseIDStr)
+ params := map[string]string{"release_id": strconv.Itoa(releaseID), "tag_names": tagNames}
+ h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end", "limit", "offset", "order_by", "sort_order"})
+ h.fetchData(&models.RelatedTagsResponse{}, c, "/release/related_tags", params)
+}
- for _, param := range optionalParams {
+func (h *FREDHandler) GetReleaseTables(c *gin.Context) {
+ releaseIDStr := c.Query("release_id")
+ if releaseIDStr == "" {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ ErrorCode: "BadRequest",
+ ErrorMessage: "release_id is required",
+ })
+ return
+ }
+ releaseID, _ := strconv.Atoi(releaseIDStr)
+ params := map[string]string{"release_id": strconv.Itoa(releaseID)}
+ h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end"})
+ h.fetchData(&models.ReleaseTablesResponse{}, c, "/release/tables", params)
+}
+
+func (h *FREDHandler) GetSources(c *gin.Context) {
+ params := map[string]string{}
+ h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end", "limit", "offset", "sort_order"})
+ h.fetchData(&models.SourcesResponse{}, c, "/sources", params)
+}
+
+func (h *FREDHandler) GetSource(c *gin.Context) {
+ sourceIDStr := c.Query("source_id")
+ if sourceIDStr == "" {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ ErrorCode: "BadRequest",
+ ErrorMessage: "source_id is required",
+ })
+ return
+ }
+ sourceID, _ := strconv.Atoi(sourceIDStr)
+ params := map[string]string{"source_id": strconv.Itoa(sourceID)}
+ h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end"})
+ h.fetchData(&models.SourceResponse{}, c, "/source", params)
+}
+
+func (h *FREDHandler) GetSourceReleases(c *gin.Context) {
+ sourceIDStr := c.Query("source_id")
+ if sourceIDStr == "" {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ ErrorCode: "BadRequest",
+ ErrorMessage: "source_id is required",
+ })
+ return
+ }
+ sourceID, _ := strconv.Atoi(sourceIDStr)
+ params := map[string]string{"source_id": strconv.Itoa(sourceID)}
+ h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end", "limit", "offset", "sort_order"})
+ h.fetchData(&models.ReleasesResponse{}, c, "/source/releases", params)
+}
+
+func (h *FREDHandler) GetTags(c *gin.Context) {
+ params := map[string]string{}
+ h.addOptionalParams(c, params, []string{
+ "realtime_start", "realtime_end", "limit", "offset", "order_by", "sort_order",
+ "tag_name", "tag_group_id", "filter",
+ })
+ h.fetchData(&models.TagsResponse{}, c, "/tags", params)
+}
+
+func (h *FREDHandler) GetRelatedTags(c *gin.Context) {
+ tagNames := c.Query("tag_names")
+ if tagNames == "" {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ ErrorCode: "BadRequest",
+ ErrorMessage: "tag_names is required",
+ })
+ return
+ }
+ params := map[string]string{"tag_names": tagNames}
+ h.addOptionalParams(c, params, []string{
+ "realtime_start", "realtime_end", "limit", "offset", "order_by", "sort_order",
+ })
+ h.fetchData(&models.RelatedTagsResponse{}, c, "/related_tags", params)
+}
+
+func (h *FREDHandler) GetTagsSeries(c *gin.Context) {
+ tagNames := c.Query("tag_names")
+ if tagNames == "" {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ ErrorCode: "BadRequest",
+ ErrorMessage: "tag_names is required",
+ })
+ return
+ }
+ params := map[string]string{"tag_names": tagNames}
+ h.addOptionalParams(c, params, []string{
+ "realtime_start", "realtime_end", "limit", "offset", "order_by", "sort_order",
+ "filter", "" +
+ "" +
+ "" +
+ "" +
+ "" +
+ "",
+ })
+ h.fetchData(&models.SearchResponse{}, c, "/tags/series", params)
+}
+
+func (h *FREDHandler) addOptionalParams(c *gin.Context, params map[string]string, optionals []string) {
+ for _, param := range optionals {
if v := c.Query(param); v != "" {
- params[param] = v
+ params[param] = strings.TrimSpace(v)
}
}
-
- url := h.buildURL("/release/series", params)
-
- var response models.SearchResponse
- if err := h.fetchData(url, &response); err != nil {
- c.JSON(http.StatusInternalServerError, models.ErrorResponse{
- ErrorCode: "InternalServerError",
- ErrorMessage: err.Error(),
- })
- return
- }
-
- c.JSON(http.StatusOK, response)
-}
-
-func (h *FREDHandler) HealthCheck(c *gin.Context) {
- c.JSON(http.StatusOK, gin.H{
- "status": "healthy",
- "service": "FRED API Proxy",
- })
}
diff --git a/main.go b/main.go
index 88cecbb..79ed69a 100644
--- a/main.go
+++ b/main.go
@@ -41,25 +41,67 @@ func main() {
api := router.Group("/api/v1")
{
+ api.GET("/category", fredHandler.GetCategory)
+ api.GET("/category/children", fredHandler.GetCategoryChildren)
+ api.GET("/category/related", fredHandler.GetCategoryRelated)
+ api.GET("/category/series", fredHandler.GetCategorySeries)
+ api.GET("/category/tags", fredHandler.GetCategoryTags)
+ api.GET("/category/related_tags", fredHandler.GetCategoryRelatedTags)
+
api.GET("/series", fredHandler.GetSeries)
+ api.GET("/series/categories", fredHandler.GetSeriesCategories)
api.GET("/series/observations", fredHandler.GetObservations)
+ api.GET("/series/release", fredHandler.GetSeriesRelease)
api.GET("/series/search", fredHandler.SearchSeries)
+ api.GET("/series/search/tags", fredHandler.SearchSeriesTags)
+ api.GET("/series/search/related_tags", fredHandler.SearchSeriesRelatedTags)
+ api.GET("/series/tags", fredHandler.GetSeriesTags)
+ api.GET("/series/updates", fredHandler.GetSeriesUpdates)
+ api.GET("/series/vintagedates", fredHandler.GetSeriesVintageDates)
+
api.GET("/releases", fredHandler.GetReleases)
api.GET("/releases/dates", fredHandler.GetReleaseDates)
+ api.GET("/release", fredHandler.GetRelease)
+ api.GET("/release/dates", fredHandler.GetReleaseDates2)
api.GET("/release/series", fredHandler.GetReleaseSeries)
+ api.GET("/release/sources", fredHandler.GetReleaseSources)
+ api.GET("/release/tags", fredHandler.GetReleaseTags)
+ api.GET("/release/related_tags", fredHandler.GetReleaseRelatedTags)
+ api.GET("/release/tables", fredHandler.GetReleaseTables)
+
+ api.GET("/sources", fredHandler.GetSources)
+ api.GET("/source", fredHandler.GetSource)
+ api.GET("/source/releases", fredHandler.GetSourceReleases)
+
+ api.GET("/tags", fredHandler.GetTags)
+ api.GET("/related_tags", fredHandler.GetRelatedTags)
+ api.GET("/tags/series", fredHandler.GetTagsSeries)
}
+ router.Static("/static", "./web/static")
+ router.LoadHTMLGlob("web/templates/*")
+
+ router.GET("/", func(c *gin.Context) {
+ c.HTML(200, "index.html", gin.H{
+ "title": "FRED Economic Data Dashboard",
+ })
+ })
+
go func() {
fmt.Printf("FRED API Server starting on :%s\n", cfg.ServerPort)
fmt.Printf("FRED API Base URL: %s\n", cfg.FREDBaseURL)
fmt.Println("\nAvailable endpoints:")
fmt.Println(" GET /health - Health check")
+ fmt.Println(" GET / - Web Dashboard")
fmt.Println(" GET /api/v1/series - Get series info")
fmt.Println(" GET /api/v1/series/observations - Get observations")
fmt.Println(" GET /api/v1/series/search - Search series")
fmt.Println(" GET /api/v1/releases - Get all releases")
fmt.Println(" GET /api/v1/releases/dates - Get release dates")
fmt.Println(" GET /api/v1/release/series - Get series in release")
+ fmt.Println(" GET /api/v1/category - Get category")
+ fmt.Println(" GET /api/v1/sources - Get sources")
+ fmt.Println(" GET /api/v1/tags - Get tags")
fmt.Println("")
fmt.Println("Examples:")
fmt.Println(" curl 'http://localhost:" + cfg.ServerPort + "/api/v1/series?series_id=GNPCA'")
diff --git a/models/models.go b/models/models.go
index 30636c4..c3e0b6c 100644
--- a/models/models.go
+++ b/models/models.go
@@ -1,5 +1,25 @@
package models
+type Category struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ ParentID int `json:"parent_id"`
+ Level int `json:"level"`
+ Notes string `json:"notes,omitempty"`
+}
+
+type CategoryResponse struct {
+ RealtimeStart string `json:"realtime_start"`
+ RealtimeEnd string `json:"realtime_end"`
+ Category *Category `json:"category"`
+}
+
+type CategoriesResponse struct {
+ RealtimeStart string `json:"realtime_start"`
+ RealtimeEnd string `json:"realtime_end"`
+ Categories []Category `json:"categories"`
+}
+
type Series struct {
ID string `json:"id"`
RealtimeStart string `json:"realtime_start"`
@@ -24,6 +44,12 @@ type SeriesListResponse struct {
Seriess []Series `json:"seriess"`
}
+type SeriesResponse struct {
+ RealtimeStart string `json:"realtime_start"`
+ RealtimeEnd string `json:"realtime_end"`
+ Series Series `json:"series"`
+}
+
type Observation struct {
RealtimeStart string `json:"realtime_start"`
RealtimeEnd string `json:"realtime_end"`
@@ -48,12 +74,19 @@ type ObservationsResponse struct {
}
type SearchResult struct {
- ID string `json:"id"`
- RealtimeStart string `json:"realtime_start"`
- RealtimeEnd string `json:"realtime_end"`
- Title string `json:"title"`
- Frequency string `json:"frequency"`
- Units string `json:"units"`
+ ID string `json:"id"`
+ RealtimeStart string `json:"realtime_start"`
+ RealtimeEnd string `json:"realtime_end"`
+ Title string `json:"title"`
+ Frequency string `json:"frequency"`
+ FrequencyShort string `json:"frequency_short"`
+ Units string `json:"units"`
+ UnitsShort string `json:"units_short"`
+ SeasonalAdjustment string `json:"seasonal_adjustment"`
+ SeasonalAdjustmentShort string `json:"seasonal_adjustment_short"`
+ LastUpdated string `json:"last_updated"`
+ Popularity int `json:"popularity"`
+ Notes string `json:"notes,omitempty"`
}
type SearchResponse struct {
@@ -86,6 +119,12 @@ type ReleasesResponse struct {
Releases []Release `json:"releases"`
}
+type ReleaseResponse struct {
+ RealtimeStart string `json:"realtime_start"`
+ RealtimeEnd string `json:"realtime_end"`
+ Release Release `json:"release"`
+}
+
type ReleaseDatesResponse struct {
RealtimeStart string `json:"realtime_start"`
RealtimeEnd string `json:"realtime_end"`
@@ -99,6 +138,130 @@ type ReleaseDatesResponse struct {
} `json:"release_dates"`
}
+type Source struct {
+ ID int `json:"id"`
+ RealtimeStart string `json:"realtime_start"`
+ RealtimeEnd string `json:"realtime_end"`
+ Name string `json:"name"`
+ Link string `json:"link"`
+ Notes string `json:"notes,omitempty"`
+}
+
+type SourcesResponse struct {
+ RealtimeStart string `json:"realtime_start"`
+ RealtimeEnd string `json:"realtime_end"`
+ Count int `json:"count"`
+ Offset int `json:"offset"`
+ Limit int `json:"limit"`
+ Sources []Source `json:"sources"`
+}
+
+type SourceResponse struct {
+ RealtimeStart string `json:"realtime_start"`
+ RealtimeEnd string `json:"realtime_end"`
+ Source Source `json:"source"`
+}
+
+type Tag struct {
+ Name string `json:"name"`
+ GroupName string `json:"group_name"`
+ Serverity string `json:"serverity"`
+ Notes string `json:"notes,omitempty"`
+ Popularity int `json:"popularity"`
+}
+
+type TagsResponse struct {
+ RealtimeStart string `json:"realtime_start"`
+ RealtimeEnd string `json:"realtime_end"`
+ Count int `json:"count"`
+ Offset int `json:"offset"`
+ Limit int `json:"limit"`
+ Tags []Tag `json:"tags"`
+}
+
+type RelatedTagsResponse struct {
+ RealtimeStart string `json:"realtime_start"`
+ RealtimeEnd string `json:"realtime_end"`
+ Count int `json:"count"`
+ Offset int `json:"offset"`
+ Limit int `json:"limit"`
+ RelatedTags []Tag `json:"related_tags"`
+}
+
+type SeriesUpdate struct {
+ ID string `json:"id"`
+ RealtimeStart string `json:"realtime_start"`
+ RealtimeEnd string `json:"realtime_end"`
+ Title string `json:"title"`
+ Frequency string `json:"frequency"`
+ Units string `json:"units"`
+ LastUpdated string `json:"last_updated"`
+}
+
+type SeriesUpdatesResponse struct {
+ RealtimeStart string `json:"realtime_start"`
+ RealtimeEnd string `json:"realtime_end"`
+ Count int `json:"count"`
+ Offset int `json:"offset"`
+ Limit int `json:"limit"`
+ Observations []SeriesUpdate `json:"seriess"`
+}
+
+type VintageDatesResponse struct {
+ VintageDates []string `json:"vintage_dates"`
+}
+
+type CategorySeriesResponse struct {
+ RealtimeStart string `json:"realtime_start"`
+ RealtimeEnd string `json:"realtime_end"`
+ Series []Series `json:"seriess"`
+}
+
+type CategoryTagsResponse struct {
+ RealtimeStart string `json:"realtime_start"`
+ RealtimeEnd string `json:"realtime_end"`
+ Tags []Tag `json:"tags"`
+}
+
+type SeriesTagsResponse struct {
+ RealtimeStart string `json:"realtime_start"`
+ RealtimeEnd string `json:"realtime_end"`
+ Tags []Tag `json:"tags"`
+}
+
+type SeriesCategoriesResponse struct {
+ RealtimeStart string `json:"realtime_start"`
+ RealtimeEnd string `json:"realtime_end"`
+ Categories []Category `json:"categories"`
+}
+
+type ReleaseSourcesResponse struct {
+ RealtimeStart string `json:"realtime_start"`
+ RealtimeEnd string `json:"realtime_end"`
+ Sources []Source `json:"sources"`
+}
+
+type ReleaseTagsResponse struct {
+ RealtimeStart string `json:"realtime_start"`
+ RealtimeEnd string `json:"realtime_end"`
+ Tags []Tag `json:"tags"`
+}
+
+type ReleaseTablesResponse struct {
+ Result struct {
+ Data []struct {
+ SeriesID string `json:"series_id"`
+ Title string `json:"title"`
+ StartDate string `json:"start_date"`
+ EndDate string `json:"end_date"`
+ LastUpdated string `json:"last_updated"`
+ Frequency string `json:"frequency"`
+ Units string `json:"units"`
+ SeasonalAdj string `json:"seasonal_adjustment"`
+ } `json:"data"`
+ } `json:"result"`
+}
+
type ErrorResponse struct {
ErrorCode string `json:"error_code"`
ErrorMessage string `json:"error_message"`
diff --git a/web/static/css/style.css b/web/static/css/style.css
new file mode 100644
index 0000000..77c8789
--- /dev/null
+++ b/web/static/css/style.css
@@ -0,0 +1,137 @@
+:root {
+ --primary-color: #1a73e8;
+ --secondary-color: #5f6368;
+ --success-color: #34a853;
+ --danger-color: #ea4335;
+ --warning-color: #fbbc04;
+ --info-color: #4285f4;
+ --dark-bg: #202124;
+ --light-bg: #f8f9fa;
+}
+
+body {
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+ background-color: #f5f5f5;
+}
+
+.navbar {
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+}
+
+.card {
+ border-radius: 8px;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.12);
+ transition: box-shadow 0.3s ease;
+}
+
+.card:hover {
+ box-shadow: 0 4px 8px rgba(0,0,0,0.15);
+}
+
+.card-header {
+ border-radius: 8px 8px 0 0 !important;
+}
+
+#chart-placeholder {
+ display: none;
+}
+
+#dataChart {
+ display: none;
+}
+
+#dataChart.active {
+ display: block;
+}
+
+.table {
+ font-size: 0.9rem;
+}
+
+.table th {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+}
+
+.pagination {
+ margin: 0;
+}
+
+.spinner-border-sm {
+ width: 1rem;
+ height: 1rem;
+ border-width: 0.15em;
+}
+
+.series-item {
+ padding: 8px 12px;
+ cursor: pointer;
+ border-radius: 4px;
+ transition: background-color 0.2s;
+}
+
+.series-item:hover {
+ background-color: #e8f0fe;
+}
+
+.series-item.active {
+ background-color: #1a73e8;
+ color: white;
+}
+
+.category-item {
+ padding: 6px 10px;
+ cursor: pointer;
+ font-size: 0.9rem;
+ border-radius: 4px;
+ transition: all 0.2s;
+}
+
+.category-item:hover {
+ background-color: #e8f5e9;
+}
+
+.release-item {
+ padding: 8px;
+ border-bottom: 1px solid #eee;
+ cursor: pointer;
+}
+
+.release-item:hover {
+ background-color: #f8f9fa;
+}
+
+.release-item:last-child {
+ border-bottom: none;
+}
+
+.release-date {
+ font-size: 0.8rem;
+ color: #666;
+}
+
+#current-time {
+ font-family: monospace;
+ font-size: 0.9rem;
+}
+
+.btn-outline-light:hover {
+ background-color: rgba(255,255,255,0.2);
+}
+
+footer {
+ margin-top: 2rem;
+}
+
+.form-control:focus, .form-select:focus {
+ border-color: #1a73e8;
+ box-shadow: 0 0 0 0.2rem rgba(26, 115, 232, 0.25);
+}
+
+.toast-container {
+ position: fixed;
+ bottom: 20px;
+ right: 20px;
+ z-index: 1050;
+}
\ No newline at end of file
diff --git a/web/static/js/app.js b/web/static/js/app.js
new file mode 100644
index 0000000..66e16ba
--- /dev/null
+++ b/web/static/js/app.js
@@ -0,0 +1,385 @@
+const API_BASE = '/api/v1';
+let chart = null;
+let currentSeriesData = [];
+let currentPage = 1;
+const itemsPerPage = 20;
+
+const popularSeries = ['GNPCA', 'GDP', 'UNRATE', 'CPIAUCSL', 'FEDFUNDS', 'M2SL', 'SP500', 'EXUSEU'];
+
+function init() {
+ updateTime();
+ setInterval(updateTime, 1000);
+ loadPopularSeries();
+ loadCategories();
+ loadReleases();
+ initChart();
+}
+
+function updateTime() {
+ const now = new Date();
+ document.getElementById('current-time').textContent = now.toLocaleString();
+}
+
+function initChart() {
+ const ctx = document.getElementById('dataChart').getContext('2d');
+ chart = new Chart(ctx, {
+ type: 'line',
+ data: {
+ labels: [],
+ datasets: [{
+ label: 'Value',
+ data: [],
+ borderColor: '#1a73e8',
+ backgroundColor: 'rgba(26, 115, 232, 0.1)',
+ fill: true,
+ tension: 0.3,
+ pointRadius: 3,
+ pointHoverRadius: 6
+ }]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: {
+ display: true,
+ position: 'top'
+ },
+ tooltip: {
+ mode: 'index',
+ intersect: false
+ }
+ },
+ scales: {
+ x: {
+ ticks: {
+ maxRotation: 45,
+ minRotation: 45
+ }
+ },
+ y: {
+ beginAtZero: false
+ }
+ },
+ interaction: {
+ mode: 'nearest',
+ axis: 'x',
+ intersect: false
+ }
+ }
+ });
+}
+
+async function searchSeries() {
+ const searchText = document.getElementById('search-input').value.trim();
+ if (!searchText) {
+ alert('Please enter a search term');
+ return;
+ }
+
+ try {
+ const response = await axios.get(`${API_BASE}/series/search`, {
+ params: { search_text: searchText, limit: 20 }
+ });
+
+ if (response.data && response.data.seriess && response.data.seriess.length > 0) {
+ displaySearchResults(response.data.seriess);
+ } else {
+ alert('No results found');
+ }
+ } catch (error) {
+ console.error('Search error:', error);
+ alert('Search failed: ' + (error.response?.data?.error_message || error.message));
+ }
+}
+
+function displaySearchResults(results) {
+ const select = document.getElementById('series-select');
+ select.innerHTML = '';
+
+ results.forEach(series => {
+ const option = document.createElement('option');
+ option.value = series.id;
+ option.textContent = `${series.id} - ${series.title}`;
+ select.appendChild(option);
+ });
+
+ if (results.length > 0) {
+ select.value = results[0].id;
+ loadSeriesData();
+ }
+}
+
+async function loadPopularSeries() {
+ const select = document.getElementById('series-select');
+ select.innerHTML = '';
+
+ popularSeries.forEach(seriesId => {
+ const option = document.createElement('option');
+ option.value = seriesId;
+ option.textContent = seriesId;
+ select.appendChild(option);
+ });
+
+ if (popularSeries.length > 0) {
+ select.value = popularSeries[0];
+ loadSeriesData();
+ }
+}
+
+async function loadSeriesData() {
+ const seriesId = document.getElementById('series-select').value;
+ if (!seriesId) {
+ document.getElementById('chart-placeholder').style.display = 'block';
+ document.getElementById('dataChart').style.display = 'none';
+ return;
+ }
+
+ try {
+ const [seriesRes, obsRes] = await Promise.all([
+ axios.get(`${API_BASE}/series`, { params: { series_id: seriesId } }),
+ axios.get(`${API_BASE}/series/observations`, { params: { series_id: seriesId, limit: 1000 } })
+ ]);
+
+ const series = seriesRes.data.series;
+ const observations = obsRes.data.observations || [];
+
+ displaySeriesInfo(series);
+ displayChart(observations, seriesId);
+ currentSeriesData = observations;
+ renderTable(1);
+ updatePagination();
+
+ document.getElementById('chart-placeholder').style.display = 'none';
+ document.getElementById('dataChart').style.display = 'block';
+ } catch (error) {
+ console.error('Load series error:', error);
+ alert('Failed to load series: ' + (error.response?.data?.error_message || error.message));
+ }
+}
+
+function displaySeriesInfo(series) {
+ const info = document.getElementById('series-info');
+ info.innerHTML = `
+
+
+
Series ID: ${series.id}
+
Title: ${series.title}
+
Frequency: ${series.frequency}
+
+
+
Units: ${series.units}
+
Seasonal Adjustment: ${series.seasonal_adjustment}
+
Last Updated: ${series.last_updated}
+
+
+ ${series.notes ? `Notes: ${series.notes}
` : ''}
+ `;
+ document.getElementById('chart-title').textContent = series.title || series.id;
+}
+
+function displayChart(observations, seriesId) {
+ const labels = observations.map(o => o.date).reverse();
+ const data = observations.map(o => parseFloat(o.value) || null).reverse();
+
+ chart.data.labels = labels;
+ chart.data.datasets[0].data = data;
+ chart.data.datasets[0].label = seriesId;
+ chart.update();
+}
+
+function renderTable(page) {
+ currentPage = page;
+ const start = (page - 1) * itemsPerPage;
+ const end = start + itemsPerPage;
+ const pageData = currentSeriesData.slice(start, end);
+
+ const tbody = document.getElementById('data-table-body');
+ if (pageData.length === 0) {
+ tbody.innerHTML = '| No data available |
';
+ return;
+ }
+
+ tbody.innerHTML = pageData.map(obs => `
+
+ | ${obs.date} |
+ ${obs.value || 'N/A'} |
+
+ `).join('');
+}
+
+function updatePagination() {
+ const totalPages = Math.ceil(currentSeriesData.length / itemsPerPage);
+ const pagination = document.getElementById('pagination');
+
+ if (totalPages <= 1) {
+ pagination.innerHTML = '';
+ return;
+ }
+
+ let html = '';
+
+ if (currentPage > 1) {
+ html += `Prev`;
+ }
+
+ for (let i = 1; i <= totalPages; i++) {
+ if (i === currentPage) {
+ html += `${i}`;
+ } else if (i === 1 || i === totalPages || (i >= currentPage - 2 && i <= currentPage + 2)) {
+ html += `${i}`;
+ } else if (i === currentPage - 3 || i === currentPage + 3) {
+ html += `...`;
+ }
+ }
+
+ if (currentPage < totalPages) {
+ html += `Next`;
+ }
+
+ pagination.innerHTML = html;
+}
+
+async function loadCategories() {
+ try {
+ const response = await axios.get(`${API_BASE}/category/children`, {
+ params: { category_id: 0 }
+ });
+
+ const list = document.getElementById('categories-list');
+ if (response.data && response.data.categories) {
+ list.innerHTML = response.data.categories.map(cat => `
+
+ ${cat.name}
+
+ `).join('');
+ }
+ } catch (error) {
+ console.error('Load categories error:', error);
+ }
+}
+
+async function loadCategorySeries(categoryId, categoryName) {
+ try {
+ const response = await axios.get(`${API_BASE}/category/series`, {
+ params: { category_id: categoryId, limit: 10 }
+ });
+
+ const select = document.getElementById('series-select');
+ select.innerHTML = ``;
+
+ if (response.data && response.data.seriess) {
+ response.data.seriess.forEach(series => {
+ const option = document.createElement('option');
+ option.value = series.id;
+ option.textContent = `${series.id} - ${series.title}`;
+ select.appendChild(option);
+ });
+
+ if (response.data.seriess.length > 0) {
+ select.value = response.data.seriess[0].id;
+ loadSeriesData();
+ }
+ }
+ } catch (error) {
+ console.error('Load category series error:', error);
+ }
+}
+
+async function loadReleases() {
+ try {
+ const response = await axios.get(`${API_BASE}/releases/dates`, {
+ params: { limit: 10, start_date: getDateDaysAgo(30) }
+ });
+
+ const list = document.getElementById('releases-list');
+ if (response.data && response.data.release_dates) {
+ list.innerHTML = response.data.release_dates.map(rel => `
+
+
${rel.release_name}
+
${rel.date}
+
+ `).join('');
+ }
+ } catch (error) {
+ console.error('Load releases error:', error);
+ }
+}
+
+async function loadReleaseSeries(releaseId, releaseName) {
+ try {
+ const response = await axios.get(`${API_BASE}/release/series`, {
+ params: { release_id: releaseId, limit: 10 }
+ });
+
+ const select = document.getElementById('series-select');
+ select.innerHTML = ``;
+
+ if (response.data && response.data.seriess) {
+ response.data.seriess.forEach(series => {
+ const option = document.createElement('option');
+ option.value = series.id;
+ option.textContent = `${series.id} - ${series.title}`;
+ select.appendChild(option);
+ });
+
+ if (response.data.seriess.length > 0) {
+ select.value = response.data.seriess[0].id;
+ loadSeriesData();
+ }
+ }
+ } catch (error) {
+ console.error('Load release series error:', error);
+ }
+}
+
+function showSeriesModal() {
+ const modal = new bootstrap.Modal(document.getElementById('addSeriesModal'));
+ modal.show();
+}
+
+function addSeriesFromModal() {
+ const seriesId = document.getElementById('modal-series-id').value.trim();
+ if (!seriesId) {
+ alert('Please enter a series ID');
+ return;
+ }
+
+ document.getElementById('series-select').value = seriesId;
+ bootstrap.Modal.getInstance(document.getElementById('addSeriesModal')).hide();
+ loadSeriesData();
+}
+
+function exportData() {
+ if (!currentSeriesData.length) {
+ alert('No data to export');
+ return;
+ }
+
+ let csv = 'Date,Value\n';
+ currentSeriesData.forEach(obs => {
+ csv += `${obs.date},${obs.value}\n`;
+ });
+
+ const blob = new Blob([csv], { type: 'text/csv' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `fred_data_${new Date().toISOString().split('T')[0]}.csv`;
+ a.click();
+ URL.revokeObjectURL(url);
+}
+
+function getDateDaysAgo(days) {
+ const date = new Date();
+ date.setDate(date.getDate() - days);
+ return date.toISOString().split('T')[0];
+}
+
+document.addEventListener('DOMContentLoaded', init);
+
+document.getElementById('search-input').addEventListener('keypress', function(e) {
+ if (e.key === 'Enter') {
+ searchSeries();
+ }
+});
\ No newline at end of file
diff --git a/web/templates/index.html b/web/templates/index.html
new file mode 100644
index 0000000..8d20cdb
--- /dev/null
+++ b/web/templates/index.html
@@ -0,0 +1,183 @@
+
+
+
+
+
+ FRED Economic Data Dashboard
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Select or search for a series to visualize data
+
+
+
+
+
+
+
+
+
+
+
+ | Date |
+ Value |
+
+
+
+
+ | No data available |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Select a series to view details
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file