Compare commits

..
Author SHA1 Message Date
cody 35b0c2396f add the very basics of a plot
Also troubleshoot to fix vite config for deduplicating react and
react-dom
2026-02-18 19:52:08 -08:00
cody e7d8d09fbc extract latest weather as a component 2026-02-18 16:44:11 -08:00
cody 9150a069fe cleanup 2026-02-18 16:21:47 -08:00
cody b49349eb26 major refactor of main.go 2026-02-18 15:35:56 -08:00
cody be7e8a3c33 add initial frontend 2026-02-17 17:40:00 -08:00
cody 4b05aa7bf3 add a new test endpoint
testing deploy pipeline
2026-02-12 23:56:54 -08:00
cody ef183ab1ce add health route 2026-02-12 23:28:10 -08:00
cody d2e57f0af4 update git ignore 2026-02-12 23:28:03 -08:00
cody 40a9bb4724 fix build tag 2026-02-12 23:27:56 -08:00
cody b4f819eba9 update go version... 2026-02-12 22:44:04 -08:00
cody f649e91d57 change gitea action to use ubuntu latest 2026-02-12 22:25:11 -08:00
cody d2c0600359 add github action, build script, docker ignore 2026-02-12 22:21:53 -08:00
36 changed files with 2503 additions and 233 deletions
+2
View File
@@ -0,0 +1,2 @@
.env
.git
+25
View File
@@ -0,0 +1,25 @@
name: Build
on:
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: gitea.coupxd.com
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and Push
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/arm64
push: true
tags: gitea.coupxd.com/cody/go-weather:latest
+2
View File
@@ -1 +1,3 @@
.env
.dockerenv
node_modules
+12
View File
@@ -0,0 +1,12 @@
FROM golang:1.25-bookworm AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Buildx automatically injects TARGETARCH, TARGETOS, and TARGETPLATFORM as build args into the Dockerfile
ARG TARGETARCH
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -o go-weather .
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/go-weather /go-weather
EXPOSE 8080
ENTRYPOINT ["/go-weather"]
Executable
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
echo "Creating build environment"
docker buildx create --name pibuilder --use
echo "Printing build environment info"
docker buildx inspect --bootstrap
echo "Doing build"
docker buildx build \
--platform linux/arm64 \
--push \
-t gitea.coupxd.com/cody/go-weather:latest \
.
+98
View File
@@ -0,0 +1,98 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"dependencies": {
"recharts": "^3.7.0",
},
},
},
"packages": {
"@reduxjs/toolkit": ["@reduxjs/[email protected]", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="],
"@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@standard-schema/utils": ["@standard-schema/[email protected]", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
"@types/d3-array": ["@types/[email protected]", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
"@types/d3-color": ["@types/[email protected]", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
"@types/d3-ease": ["@types/[email protected]", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
"@types/d3-interpolate": ["@types/[email protected]", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
"@types/d3-path": ["@types/[email protected]", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
"@types/d3-scale": ["@types/[email protected]", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
"@types/d3-shape": ["@types/[email protected]", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="],
"@types/d3-time": ["@types/[email protected]", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
"@types/d3-timer": ["@types/[email protected]", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/use-sync-external-store": ["@types/[email protected]", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
"clsx": ["[email protected]", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"d3-array": ["[email protected]", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
"d3-color": ["[email protected]", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
"d3-ease": ["[email protected]", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
"d3-format": ["[email protected]", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
"d3-interpolate": ["[email protected]", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-path": ["[email protected]", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3-scale": ["[email protected]", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
"d3-shape": ["[email protected]", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
"d3-time": ["[email protected]", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
"d3-time-format": ["[email protected]", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
"d3-timer": ["[email protected]", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"decimal.js-light": ["[email protected]", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
"es-toolkit": ["[email protected]", "", {}, "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg=="],
"eventemitter3": ["[email protected]", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
"immer": ["[email protected]", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
"internmap": ["[email protected]", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"react": ["[email protected]", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-dom": ["[email protected]", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"react-is": ["[email protected]", "", {}, "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA=="],
"react-redux": ["[email protected]", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
"recharts": ["[email protected]", "", { "dependencies": { "@reduxjs/toolkit": "1.x.x || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew=="],
"redux": ["[email protected]", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="],
"redux-thunk": ["[email protected]", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
"reselect": ["[email protected]", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
"scheduler": ["[email protected]", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"tiny-invariant": ["[email protected]", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"use-sync-external-store": ["[email protected]", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"victory-vendor": ["[email protected]", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
"@reduxjs/toolkit/immer": ["[email protected]", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="],
}
}
+36
View File
@@ -0,0 +1,36 @@
package config
import (
"log"
"os"
)
type Config struct {
APIKey string
DefaultCity string
DBHost string
DBPort string
DBUser string
DBPassword string
DBName string
}
func GetEnv(key string) string {
val := os.Getenv(key)
if val == "" {
log.Fatalf("missing required env var: %s", key)
}
return val
}
func Load() *Config {
return &Config{
APIKey: GetEnv("OPENWEATHERMAP_API_KEY"),
DefaultCity: GetEnv("WEATHER_CITY"),
DBHost: GetEnv("DB_HOST"),
DBPort: GetEnv("DB_PORT"),
DBUser: GetEnv("DB_USER"),
DBPassword: GetEnv("DB_PASSWORD"),
DBName: GetEnv("DB_NAME"),
}
}
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+1306
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "gray",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+40
View File
@@ -0,0 +1,40 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.90.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.574.0",
"radix-ui": "^1.4.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwind-merge": "^3.4.1",
"tailwindcss": "^4.1.18"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^25.2.3",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"shadcn": "^3.8.5",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}
+42
View File
@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
+24
View File
@@ -0,0 +1,24 @@
import './App.css'
import LatestTemperature from "@/components/latest-temperature"
import TemperatureHistory from "@/components/temperature-history"
import TemperaturePlot from "@/components/temperature-plot"
// import { Button } from "@/components/ui/button"
function App() {
return (
<main className="min-h-screen bg-background p-6">
<div className="max-w-4xl mx-auto space-y-2">
<TemperaturePlot />
<div grid grid-cols-1 md:grid-cols-1 gap-6>
<LatestTemperature />
<TemperatureHistory />
</div>
</div>
</main>
)
}
export default App
@@ -0,0 +1,30 @@
import { useQuery } from '@tanstack/react-query'
interface LatestTemperature {
temp: number;
}
async function fetchLatestTemperature(): Promise<LatestTemperature>{
const res = await fetch("/api/latest-temperature");
if (!res.ok) throw new Error("Failed to fetch latest temperature");
return res.json();
}
function LatestTemperature(){
const {data, isLoading, error} = useQuery({
queryKey: ["latest-temperature"],
queryFn: fetchLatestTemperature,
});
if (isLoading) return <p>Loading...</p>
if (error) return <p>Error: {error.message}</p>
return (
<>
<p>{data?.temp ?? 'N/A'}</p>
</>
)
}
export default LatestTemperature
@@ -0,0 +1,19 @@
import WeatherHistory from '@/hooks/weather-history'
function TemperatureHistory(){
const {data, isLoading, error} = WeatherHistory()
if (isLoading) return <p>Loading...</p>
if (error) return <p>Error: {error.message}</p>
return (
<>
{data.map((record) => (
<div key={record.id}>{record.temp ?? 'N/A'}({record.city})</div>
))}
</>
)
}
export default TemperatureHistory
@@ -0,0 +1,25 @@
import { ScatterChart, Scatter, XAxis, YAxis, Tooltip, ResponsiveContainer } from "recharts";
import WeatherHistory from '@/hooks/weather-history'
function TemperaturePlot() {
const {data, isLoading, error} = WeatherHistory()
if (isLoading) return <p>Loading...</p>
if (error) return <p>Error: {error.message}</p>
return (
<div>
<ResponsiveContainer width="100%" height={400}>
<ScatterChart data={data}>
<XAxis dataKey="id" />
<YAxis />
<Tooltip />
<Scatter dataKey="temp" fill="#6366f1" />
</ScatterChart>
</ResponsiveContainer>
</div>
)
}
export default TemperaturePlot
+64
View File
@@ -0,0 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+29
View File
@@ -0,0 +1,29 @@
import { useQuery } from '@tanstack/react-query'
// {"id":1001,"city":"Salem","temp":66.7,"feels_like":67.17,"humidity":87,"conditions":"scattered clouds","fetched_at":"2026-02-18T22:16:38.067097Z"}
interface WeatherHistory {
id: number;
city: string;
temp: number;
feels_like: number;
humidity: number;
conditions: string;
fetched_at: string;
}
async function fetchWeatherHistory(): Promise<WeatherHistory[]>{
const res = await fetch("/api/weather/history");
if(!res.ok) throw new Error("Failed to fetch weather history");
return res.json();
}
function WeatherHistory() {
const {data, isLoading, error} = useQuery({
queryKey: ["weather-history"],
queryFn: fetchWeatherHistory,
});
return {data, isLoading, error}
}
export default WeatherHistory
+189
View File
@@ -0,0 +1,189 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.13 0.028 261.692);
--card: oklch(1 0 0);
--card-foreground: oklch(0.13 0.028 261.692);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.13 0.028 261.692);
--primary: oklch(0.21 0.034 264.665);
--primary-foreground: oklch(0.985 0.002 247.839);
--secondary: oklch(0.967 0.003 264.542);
--secondary-foreground: oklch(0.21 0.034 264.665);
--muted: oklch(0.967 0.003 264.542);
--muted-foreground: oklch(0.551 0.027 264.364);
--accent: oklch(0.967 0.003 264.542);
--accent-foreground: oklch(0.21 0.034 264.665);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.928 0.006 264.531);
--input: oklch(0.928 0.006 264.531);
--ring: oklch(0.707 0.022 261.325);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0.002 247.839);
--sidebar-foreground: oklch(0.13 0.028 261.692);
--sidebar-primary: oklch(0.21 0.034 264.665);
--sidebar-primary-foreground: oklch(0.985 0.002 247.839);
--sidebar-accent: oklch(0.967 0.003 264.542);
--sidebar-accent-foreground: oklch(0.21 0.034 264.665);
--sidebar-border: oklch(0.928 0.006 264.531);
--sidebar-ring: oklch(0.707 0.022 261.325);
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
.dark {
--background: oklch(0.13 0.028 261.692);
--foreground: oklch(0.985 0.002 247.839);
--card: oklch(0.21 0.034 264.665);
--card-foreground: oklch(0.985 0.002 247.839);
--popover: oklch(0.21 0.034 264.665);
--popover-foreground: oklch(0.985 0.002 247.839);
--primary: oklch(0.928 0.006 264.531);
--primary-foreground: oklch(0.21 0.034 264.665);
--secondary: oklch(0.278 0.033 256.848);
--secondary-foreground: oklch(0.985 0.002 247.839);
--muted: oklch(0.278 0.033 256.848);
--muted-foreground: oklch(0.707 0.022 261.325);
--accent: oklch(0.278 0.033 256.848);
--accent-foreground: oklch(0.985 0.002 247.839);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.551 0.027 264.364);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.034 264.665);
--sidebar-foreground: oklch(0.985 0.002 247.839);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0.002 247.839);
--sidebar-accent: oklch(0.278 0.033 256.848);
--sidebar-accent-foreground: oklch(0.985 0.002 247.839);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.551 0.027 264.364);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+15
View File
@@ -0,0 +1,15 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import './index.css'
import App from './App.tsx'
const queryClient = new QueryClient();
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
)
+36
View File
@@ -0,0 +1,36 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
/* For Shad CN */
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
},
},
"include": ["src"]
}
+17
View File
@@ -0,0 +1,17 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+23
View File
@@ -0,0 +1,23 @@
import path from "path"
import tailwindcss from "@tailwindcss/vite"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
dedupe: ['react', 'react-dom'],
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
proxy: {
"/api": {
target: "http://localhost:8080",
rewrite: (path) => path.replace(/^\/api/, ""),
},
},
},
})
+1 -1
View File
@@ -1,4 +1,4 @@
module gitea.coupxd.com/cody/go-weather
module go-weather
go 1.25.6
+32
View File
@@ -0,0 +1,32 @@
package handlers
import (
"encoding/json"
"go-weather/models"
"net/http"
)
func Health(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.HealthResponse{Status: "ok"})
}
func LatestTemperature(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.LatestTemperatureResponse{Temp: 69})
}
func Test(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.TestResponse{Status: "hello from go"})
}
+72
View File
@@ -0,0 +1,72 @@
package handlers
import (
"encoding/json"
"fmt"
"go-weather/services"
"net/http"
)
type WeatherHandler struct {
weatherService *services.WeatherService
dbService *services.DatabaseService
config struct {
DefaultCity string
}
}
func NewWeatherHandler(ws *services.WeatherService, ds *services.DatabaseService, defaultCity string) *WeatherHandler {
return &WeatherHandler{
weatherService: ws,
dbService: ds,
config: struct{ DefaultCity string }{DefaultCity: defaultCity},
}
}
func (h *WeatherHandler) FetchWeather(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
city := r.URL.Query().Get("city")
if city == "" {
city = h.config.DefaultCity
}
weather, err := h.weatherService.FetchWeather(city)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
id, err := h.dbService.SaveReading(weather)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"message": "weather fetched and saved",
"id": id,
"city": weather.Name,
"temp": weather.Main.Temp,
})
}
func (h *WeatherHandler) GetHistory(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
city := r.URL.Query().Get("city")
if city == "" {
city = h.config.DefaultCity
}
readings, err := h.dbService.GetReadings(city, 25)
fmt.Printf("Length of readings: %v", len(readings))
fmt.Printf("HELLO")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(readings)
}
+42 -228
View File
@@ -2,280 +2,94 @@ package main
import (
"database/sql"
"encoding/json"
"fmt"
_ "github.com/lib/pq"
"go-weather/config"
"go-weather/handlers"
"go-weather/services"
"log"
"net/http"
"os"
"os/signal"
"time"
_ "github.com/lib/pq"
)
type WeatherResponse struct {
Name string `json:"name"`
Main struct {
Temp float64 `json:"temp"`
FeelsLike float64 `json:"feels_like"`
Humidity int `json:"humidity"`
}
Weather []struct {
Description string `json:"description"`
} `json:"weather"`
}
type Reading struct {
ID int `json:"id"`
City string `json:"city"`
Temp float64 `json:"temp"`
FeelsLike float64 `json:"feels_like"`
Humidity int `json:"humidity"`
Conditions string `json:"conditions"`
FetchedAt time.Time `json:"fetched_at"`
}
func getEnv(key string) string {
val := os.Getenv(key)
if val == "" {
log.Fatalf("missing required env var: %s", key)
}
return val
}
func connectDB() *sql.DB {
connStr := fmt.Sprintf(
"host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
getEnv("DB_HOST"),
getEnv("DB_PORT"),
getEnv("DB_USER"),
getEnv("DB_PASSWORD"),
getEnv("DB_NAME"),
)
db, err := sql.Open("postgres", connStr)
if err != nil {
log.Fatalf("failed to connect to db: %v", err)
}
err = db.Ping()
if err != nil {
log.Fatalf("failed to connect to db: %v", err)
}
fmt.Println("connected to PostgreSQL")
return db
}
func createTable(db *sql.DB) {
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS weather_readings (
id SERIAL PRIMARY KEY,
city TEXT NOT NULL,
temp DOUBLE PRECISION NOT NULL,
feels_like DOUBLE PRECISION NOT NULL,
humidity INTEGER NOT NULL,
conditions TEXT NOT NULL,
fetched_at TIMESTAMP WITH TIME ZONE NOT NULL
)
`)
if err != nil {
log.Fatalf("failed to create table: %v", err)
}
fmt.Println("weather_readings table ready")
}
func saveReading(db *sql.DB, weather WeatherResponse) (int, error) {
conditions := ""
if len(weather.Weather) > 0 {
conditions = weather.Weather[0].Description
}
var id int
err := db.QueryRow(
`INSERT INTO weather_readings (city, temp, feels_like, humidity, conditions, fetched_at)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id`,
weather.Name,
weather.Main.Temp,
weather.Main.FeelsLike,
weather.Main.Humidity,
conditions,
time.Now(),
).Scan(&id)
if err != nil {
return 0, fmt.Errorf("failed to save reading: %w", err)
}
return id, nil
}
func getReadings(db *sql.DB, city string, limit int) ([]Reading, error) {
rows, err := db.Query(
`SELECT id, city, temp, feels_like, humidity, conditions, fetched_at
FROM weather_readings
WHERE city ILIKE $1
ORDER BY fetched_at DESC
LIMIT $2`,
city, limit,
)
if err != nil {
return nil, fmt.Errorf("Query failed: %w", err)
}
defer rows.Close()
var readings []Reading
for rows.Next() {
var r Reading
err := rows.Scan(&r.ID, &r.City, &r.Temp, &r.FeelsLike, &r.Humidity, &r.Conditions, &r.FetchedAt)
if err != nil {
return nil, fmt.Errorf("scan failed: %w", err)
}
readings = append(readings, r)
}
return readings, nil
}
func fetchWeather(apiKey, city string) (WeatherResponse, error) {
url := fmt.Sprintf(
"https://api.openweathermap.org/data/2.5/weather?q=%s&appid=%s&units=imperial",
city, apiKey,
)
resp, err := http.Get(url)
if err != nil {
return WeatherResponse{}, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return WeatherResponse{}, fmt.Errorf("API returned status %d", resp.StatusCode)
}
var weather WeatherResponse
err = json.NewDecoder(resp.Body).Decode(&weather)
if err != nil {
return WeatherResponse{}, fmt.Errorf("failed to parse response: %w", err)
}
return weather, nil
}
func startPoller(db *sql.DB, apiKey, city string, interval time.Duration) {
weather, err := fetchWeather(apiKey, city)
func startPoller(db *sql.DB, ws *services.WeatherService, defaultCity string, interval time.Duration) {
// Use the service
weather, err := ws.FetchWeather(defaultCity)
if err != nil {
log.Printf("poller: fetch failed: %v", err)
} else {
id, err := saveReading(db, weather)
ds := services.NewDatabaseService(db)
id, err := ds.SaveReading(weather)
if err != nil {
log.Printf("poller: save failed %v", err)
} else {
log.Printf("poller: saved reading #%d for %s (%.1f F)", id, weather.Name, weather.Main.Temp)
}
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
weather, err := fetchWeather(apiKey, city)
weather, err := ws.FetchWeather(defaultCity)
if err != nil {
log.Printf("poller fetch failed: %v", err)
continue // don't crash
continue
}
id, err := saveReading(db, weather)
ds := services.NewDatabaseService(db)
id, err := ds.SaveReading(weather)
if err != nil {
log.Printf("poller: save failed: %v", err)
continue
}
log.Printf("poller: saved reading #%d for %s (%.1f F)", id, weather.Name, weather.Main.Temp)
}
}
func main() {
apiKey := getEnv("OPENWEATHERMAP_API_KEY")
defaultCity := getEnv("WEATHER_CITY")
config := config.Load()
// Connect to DB
connStr := fmt.Sprintf(
"host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
config.DBHost, config.DBPort, config.DBUser, config.DBPassword, config.DBName,
)
db := connectDB()
defer db.Close()
createTable(db)
// Start background polling in a goroutine
go startPoller(db, apiKey, defaultCity, 10*time.Minute)
// POST /weather/fetch?city=London - fetch current weather and save it
http.HandleFunc("/weather/fetch", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
city := r.URL.Query().Get("city")
if city == "" {
city = defaultCity
}
weather, err := fetchWeather(apiKey, city)
database, err := services.ConnectDB(connStr)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
log.Fatalf("failed to connect to db: %v", err)
}
defer database.Close()
databaseService := services.NewDatabaseService(database)
// Setup weather_readings table
if err := databaseService.CreateTable(); err != nil {
log.Fatalf("failed to create table: %v", err)
}
// note closure
id, err := saveReading(db, weather)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Create services
weatherService := services.NewWeatherService(config.APIKey)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"message": "weather fetched and saved",
"id": id,
"city": weather.Name,
"temp": weather.Main.Temp,
})
})
// Create route handlers
weatherHandler := handlers.NewWeatherHandler(weatherService, databaseService, config.DefaultCity)
// GET /weather/history?city=London&limit=10
http.HandleFunc("/weather/history", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
city := r.URL.Query().Get("city")
if city == "" {
city = defaultCity
}
// note closure
readings, err := getReadings(db, city, 25)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(readings)
})
// Start background poller
go startPoller(database, weatherService, config.DefaultCity, 10*time.Minute)
// Register routes
http.HandleFunc("/weather/fetch", weatherHandler.FetchWeather)
http.HandleFunc("/weather/history", weatherHandler.GetHistory)
http.HandleFunc("/health", handlers.Health)
http.HandleFunc("/latest-temperature", handlers.LatestTemperature)
http.HandleFunc("/test", handlers.Test)
// Start server
go func() {
fmt.Println("server listening on :8080")
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatalf("server failed: %v", err)
}
}()
// wait for Ctrl+C
// Wait for shutdown
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt)
<-quit
fmt.Println("\nshutting down...")
}
+40
View File
@@ -0,0 +1,40 @@
package models
import (
"time"
)
type WeatherResponse struct {
Name string `json:"name"`
Main struct {
Temp float64 `json:"temp"`
FeelsLike float64 `json:"feels_like"`
Humidity int `json:"humidity"`
}
Weather []struct {
Description string `json:"description"`
} `json:"weather"`
}
type Reading struct {
ID int `json:"id"`
City string `json:"city"`
Temp float64 `json:"temp"`
FeelsLike float64 `json:"feels_like"`
Humidity int `json:"humidity"`
Conditions string `json:"conditions"`
FetchedAt time.Time `json:"fetched_at"`
}
type HealthResponse struct {
Status string `json:"status"`
}
type TestResponse struct {
Status string `json:"status"`
}
type LatestTemperatureResponse struct {
Temp int `json:"temp"`
}
+5
View File
@@ -0,0 +1,5 @@
{
"dependencies": {
"recharts": "^3.7.0"
}
}
-3
View File
@@ -1,3 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}
+106
View File
@@ -0,0 +1,106 @@
package services
import (
"database/sql"
"fmt"
_ "github.com/lib/pq"
"go-weather/models"
"log"
"time"
)
func ConnectDB(connStr string) (*sql.DB, error) {
db, err := sql.Open("postgres", connStr)
if err != nil {
return nil, fmt.Errorf("failed to connect to db: %v", err)
}
err = db.Ping()
if err != nil {
return nil, fmt.Errorf("failed to connect to db: %v", err)
}
fmt.Println("connected to PostgreSQL")
return db, nil
}
type DatabaseService struct {
db *sql.DB
}
func NewDatabaseService(db *sql.DB) *DatabaseService {
return &DatabaseService{db: db}
}
func (s *DatabaseService) CreateTable() error {
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS weather_readings (
id SERIAL PRIMARY KEY,
city TEXT NOT NULL,
temp DOUBLE PRECISION NOT NULL,
feels_like DOUBLE PRECISION NOT NULL,
humidity INTEGER NOT NULL,
conditions TEXT NOT NULL,
fetched_at TIMESTAMP WITH TIME ZONE NOT NULL
)
`)
if err != nil {
log.Printf("failed to create table: %v", err)
}
fmt.Println("weather_readings table ready")
return nil
}
func (s *DatabaseService) SaveReading(weather models.WeatherResponse) (int, error) {
conditions := ""
if len(weather.Weather) > 0 {
conditions = weather.Weather[0].Description
}
var id int
err := s.db.QueryRow(
`INSERT INTO weather_readings (city, temp, feels_like, humidity, conditions, fetched_at)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id`,
weather.Name,
weather.Main.Temp,
weather.Main.FeelsLike,
weather.Main.Humidity,
conditions,
time.Now(),
).Scan(&id)
if err != nil {
return 0, fmt.Errorf("failed to save reading: %w", err)
}
return id, nil
}
func (s *DatabaseService) GetReadings(city string, limit int) ([]models.Reading, error) {
rows, err := s.db.Query(
`SELECT id, city, temp, feels_like, humidity, conditions, fetched_at
FROM weather_readings
WHERE city ILIKE $1
ORDER BY fetched_at DESC
LIMIT $2`,
city, limit,
)
if err != nil {
return nil, fmt.Errorf("Query failed: %w", err)
}
defer rows.Close()
var readings []models.Reading
for rows.Next() {
var r models.Reading
err := rows.Scan(&r.ID, &r.City, &r.Temp, &r.FeelsLike, &r.Humidity, &r.Conditions, &r.FetchedAt)
if err != nil {
return nil, fmt.Errorf("scan failed: %w", err)
}
readings = append(readings, r)
}
return readings, nil
}
+40
View File
@@ -0,0 +1,40 @@
package services
import (
"encoding/json"
"fmt"
"go-weather/models"
"net/http"
)
type WeatherService struct {
apiKey string
}
func NewWeatherService(apiKey string) *WeatherService {
return &WeatherService{apiKey: apiKey}
}
func (s *WeatherService) FetchWeather(city string) (models.WeatherResponse, error) {
url := fmt.Sprintf(
"https://api.openweathermap.org/data/2.5/weather?q=%s&appid=%s&units=imperial",
city, s.apiKey,
)
resp, err := http.Get(url)
if err != nil {
return models.WeatherResponse{}, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return models.WeatherResponse{}, fmt.Errorf("API returned status %d", resp.StatusCode)
}
var weather models.WeatherResponse
err = json.NewDecoder(resp.Body).Decode(&weather)
if err != nil {
return models.WeatherResponse{}, fmt.Errorf("failed to parse response: %w", err)
}
return weather, nil
}