2.9.3
Tracking Refreshed cached files
This commit is contained in:
+1
-1
@@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
||||||
|
|
||||||
<link rel="stylesheet" href="./assets/about.css">
|
<link rel="stylesheet" href="./assets/about.css?v=15a">
|
||||||
|
|
||||||
<!-- Structured data for AEO / AI search -->
|
<!-- Structured data for AEO / AI search -->
|
||||||
<script type="application/ld+json">
|
<script type="application/ld+json">
|
||||||
|
|||||||
@@ -36,6 +36,16 @@ import { useColumnPopup } from './useColumnPopup.js';
|
|||||||
import { useTableScroll } from './useTableScroll.js';
|
import { useTableScroll } from './useTableScroll.js';
|
||||||
import { getActiveEvents, getLensEvent } from '../events.js';
|
import { getActiveEvents, getLensEvent } from '../events.js';
|
||||||
|
|
||||||
|
function track(event, profile) {
|
||||||
|
try {
|
||||||
|
fetch('track.php', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(profile ? { event, profile } : { event }),
|
||||||
|
}).catch(() => {});
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
export function useAppState() {
|
export function useAppState() {
|
||||||
|
|
||||||
// ── 1. LOCATION ──────────────────────────────────────────────────────
|
// ── 1. LOCATION ──────────────────────────────────────────────────────
|
||||||
@@ -66,6 +76,8 @@ export function useAppState() {
|
|||||||
|
|
||||||
const { forecast, airQuality, loading, error, now, fetchedAt, liveElev } = useForecast(location, isPro);
|
const { forecast, airQuality, loading, error, now, fetchedAt, liveElev } = useForecast(location, isPro);
|
||||||
|
|
||||||
|
useEffect(() => { track('visit'); }, []);
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [searchResults, setSearchResults] = useState([]);
|
const [searchResults, setSearchResults] = useState([]);
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
@@ -150,6 +162,7 @@ export function useAppState() {
|
|||||||
const toggleCol = (col) => setVisibleCols(prev => ({ ...prev, [col]: !prev[col] }));
|
const toggleCol = (col) => setVisibleCols(prev => ({ ...prev, [col]: !prev[col] }));
|
||||||
|
|
||||||
const activateProfile = (key) => {
|
const activateProfile = (key) => {
|
||||||
|
track('profile', key);
|
||||||
const profile = FILTER_PROFILES[key];
|
const profile = FILTER_PROFILES[key];
|
||||||
try { localStorage.setItem('sunscope_profile', key); } catch (e) { /* ignore */ }
|
try { localStorage.setItem('sunscope_profile', key); } catch (e) { /* ignore */ }
|
||||||
setActiveProfile(key);
|
setActiveProfile(key);
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ async function build() {
|
|||||||
|
|
||||||
// 8. Copy static files
|
// 8. Copy static files
|
||||||
console.log(' Copying static files...');
|
console.log(' Copying static files...');
|
||||||
for (const f of ['robots.txt', 'sitemap.xml', 'og-image.png', 'favicon.svg']) {
|
for (const f of ['robots.txt', 'sitemap.xml', 'og-image.png', 'favicon.svg', 'restore.php', 'track.php', 'stats.php']) {
|
||||||
const src = path.join(ROOT, f);
|
const src = path.join(ROOT, f);
|
||||||
if (fs.existsSync(src)) {
|
if (fs.existsSync(src)) {
|
||||||
copyFile(src, path.join(DIST, f));
|
copyFile(src, path.join(DIST, f));
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<meta name="twitter:description" content="Plain answers to the most common questions about outdoor comfort, UV safety, car heat, air quality, pollen, and how SunScope works." />
|
<meta name="twitter:description" content="Plain answers to the most common questions about outdoor comfort, UV safety, car heat, air quality, pollen, and how SunScope works." />
|
||||||
<meta name="twitter:image" content="https://sunscope.net/og-image.png" />
|
<meta name="twitter:image" content="https://sunscope.net/og-image.png" />
|
||||||
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
||||||
<link rel="stylesheet" href="./assets/about.css">
|
<link rel="stylesheet" href="./assets/about.css?v=15a">
|
||||||
<script type="application/ld+json">
|
<script type="application/ld+json">
|
||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
|
|||||||
+2
-2
@@ -70,10 +70,10 @@
|
|||||||
|
|
||||||
<link rel="preconnect" href="https://api.open-meteo.com" crossorigin />
|
<link rel="preconnect" href="https://api.open-meteo.com" crossorigin />
|
||||||
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
||||||
<link rel="stylesheet" href="./assets/sunscope.css?v=15g">
|
<link rel="stylesheet" href="./assets/sunscope.css?v=15h">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="./assets/js/main.js?v=15b"></script>
|
<script type="module" src="./assets/js/main.js?v=15c"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
<?php
|
||||||
|
// ─── SunScope — Tracking Stats ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
$data_file = __DIR__ . '/data/tracking.json';
|
||||||
|
$tracking = [];
|
||||||
|
|
||||||
|
if (file_exists($data_file)) {
|
||||||
|
$tracking = json_decode(file_get_contents($data_file), true) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort days newest first
|
||||||
|
krsort($tracking);
|
||||||
|
|
||||||
|
// Collect all profile keys seen across all days, in preferred display order
|
||||||
|
$profile_order = ['basic','home','vehicle','farming','outdoors','alltemps','showall','custom'];
|
||||||
|
$all_profiles = [];
|
||||||
|
foreach ($tracking as $day => $data) {
|
||||||
|
foreach (array_keys($data['profiles'] ?? []) as $p) {
|
||||||
|
$all_profiles[$p] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sort: preferred order first, then any extras alphabetically
|
||||||
|
$ordered = [];
|
||||||
|
foreach ($profile_order as $p) {
|
||||||
|
if (isset($all_profiles[$p])) $ordered[] = $p;
|
||||||
|
}
|
||||||
|
foreach (array_keys($all_profiles) as $p) {
|
||||||
|
if (!in_array($p, $ordered, true)) $ordered[] = $p;
|
||||||
|
}
|
||||||
|
|
||||||
|
$profile_labels = [
|
||||||
|
'basic' => 'Basic',
|
||||||
|
'home' => 'Home',
|
||||||
|
'vehicle' => 'Vehicle',
|
||||||
|
'farming' => 'Farming',
|
||||||
|
'outdoors' => 'Places',
|
||||||
|
'alltemps' => 'Temps',
|
||||||
|
'showall' => 'Show All',
|
||||||
|
'custom' => 'Custom',
|
||||||
|
];
|
||||||
|
|
||||||
|
$total_visits = array_sum(array_column($tracking, 'visits'));
|
||||||
|
$profile_totals = [];
|
||||||
|
foreach ($ordered as $p) {
|
||||||
|
$profile_totals[$p] = array_sum(array_map(fn($d) => $d['profiles'][$p] ?? 0, $tracking));
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>SunScope — Stats</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>☀️</text></svg>" />
|
||||||
|
<link href="https://fonts.bunny.net/css2?family=Fraunces:ital,wght@0,900;1,300&family=Manrope:wght@400;600&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet" />
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: Manrope, sans-serif;
|
||||||
|
background: #f5edd6;
|
||||||
|
color: #1e1208;
|
||||||
|
padding: 2rem;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
font-family: Fraunces, serif;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.logo-sun { color: #c8922a; font-weight: 900; font-style: normal; }
|
||||||
|
.logo-scope { color: #1e1208; font-weight: 300; font-style: italic; }
|
||||||
|
.subtitle {
|
||||||
|
font-family: Fraunces, serif;
|
||||||
|
font-style: italic;
|
||||||
|
color: #7a5c2a;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.summary {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.pill {
|
||||||
|
background: #fffcf2;
|
||||||
|
border: 1.5px solid #c9b08a;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
}
|
||||||
|
.pill-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: #7a5c2a;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.pill-value {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #c8922a;
|
||||||
|
}
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 500px;
|
||||||
|
background: #fffcf2;
|
||||||
|
border: 1.5px solid #c9b08a;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
thead th {
|
||||||
|
background: #1e1208;
|
||||||
|
color: #f5edd6;
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
text-align: left;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
thead th.num { text-align: right; }
|
||||||
|
tbody tr:nth-child(even) { background: #fdf8ec; }
|
||||||
|
tbody tr:hover { background: #faefd8; }
|
||||||
|
td {
|
||||||
|
padding: 0.55rem 0.9rem;
|
||||||
|
border-top: 1px solid #e8dcc4;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
td.num { text-align: right; }
|
||||||
|
td.zero { color: #c9b08a; }
|
||||||
|
tfoot td {
|
||||||
|
background: #f5edd6;
|
||||||
|
border-top: 2px solid #c9b08a;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #7a5c2a;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
background: #fffcf2;
|
||||||
|
border: 1.5px solid #c9b08a;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: #7a5c2a;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.back {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
.back a { color: #b09870; text-decoration: none; }
|
||||||
|
.back a:hover { color: #c8922a; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="header">
|
||||||
|
<div class="logo">
|
||||||
|
<span class="logo-sun">SUN</span><span class="logo-scope">Scope</span>
|
||||||
|
</div>
|
||||||
|
<div class="subtitle">Usage stats</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary">
|
||||||
|
<div class="pill">
|
||||||
|
<div class="pill-label">Total visits</div>
|
||||||
|
<div class="pill-value"><?= number_format($total_visits) ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="pill">
|
||||||
|
<div class="pill-label">Days tracked</div>
|
||||||
|
<div class="pill-value"><?= count($tracking) ?></div>
|
||||||
|
</div>
|
||||||
|
<?php foreach ($ordered as $p): ?>
|
||||||
|
<div class="pill">
|
||||||
|
<div class="pill-label"><?= htmlspecialchars($profile_labels[$p] ?? $p) ?> clicks</div>
|
||||||
|
<div class="pill-value"><?= number_format($profile_totals[$p]) ?></div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (empty($tracking)): ?>
|
||||||
|
<div class="empty">No tracking data yet.</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th class="num">Visits</th>
|
||||||
|
<?php foreach ($ordered as $p): ?>
|
||||||
|
<th class="num"><?= htmlspecialchars($profile_labels[$p] ?? $p) ?></th>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($tracking as $day => $data): ?>
|
||||||
|
<tr>
|
||||||
|
<td><?= htmlspecialchars($day) ?></td>
|
||||||
|
<td class="num <?= $data['visits'] === 0 ? 'zero' : '' ?>"><?= $data['visits'] ?></td>
|
||||||
|
<?php foreach ($ordered as $p): $n = $data['profiles'][$p] ?? 0; ?>
|
||||||
|
<td class="num <?= $n === 0 ? 'zero' : '' ?>"><?= $n === 0 ? '—' : $n ?></td>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td>Total</td>
|
||||||
|
<td class="num"><?= number_format($total_visits) ?></td>
|
||||||
|
<?php foreach ($ordered as $p): ?>
|
||||||
|
<td class="num"><?= number_format($profile_totals[$p]) ?></td>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="back"><a href="/">← Back to SunScope</a></div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
// ─── SunScope — Rudimentary tracking ─────────────────────────────────────────
|
||||||
|
// Accepts POST { event: "visit" | "profile", profile?: string }
|
||||||
|
// Writes daily tallies to data/tracking.json as { "YYYY-MM-DD": { visits, profiles: { key: n } } }
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['error' => 'Method not allowed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$event = $body['event'] ?? '';
|
||||||
|
$profile = $body['profile'] ?? '';
|
||||||
|
|
||||||
|
if (!in_array($event, ['visit', 'profile'], true)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'Invalid event']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($event === 'profile' && $profile === '') {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'Missing profile']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data_dir = __DIR__ . '/data';
|
||||||
|
$data_file = $data_dir . '/tracking.json';
|
||||||
|
|
||||||
|
if (!is_dir($data_dir)) {
|
||||||
|
mkdir($data_dir, 0755, true);
|
||||||
|
// Block direct web access to the data folder
|
||||||
|
file_put_contents($data_dir . '/.htaccess', "Deny from all\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
$fp = fopen($data_file, 'c+');
|
||||||
|
if (!$fp) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['error' => 'Could not open tracking file']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
flock($fp, LOCK_EX);
|
||||||
|
|
||||||
|
$size = fstat($fp)['size'];
|
||||||
|
$tracking = [];
|
||||||
|
if ($size > 0) {
|
||||||
|
$tracking = json_decode(fread($fp, $size), true) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$day = date('Y-m-d');
|
||||||
|
if (!isset($tracking[$day])) {
|
||||||
|
$tracking[$day] = ['visits' => 0, 'profiles' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($event === 'visit') {
|
||||||
|
$tracking[$day]['visits']++;
|
||||||
|
} else {
|
||||||
|
$tracking[$day]['profiles'][$profile] = ($tracking[$day]['profiles'][$profile] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
ftruncate($fp, 0);
|
||||||
|
rewind($fp);
|
||||||
|
fwrite($fp, json_encode($tracking, JSON_PRETTY_PRINT));
|
||||||
|
|
||||||
|
flock($fp, LOCK_UN);
|
||||||
|
fclose($fp);
|
||||||
|
|
||||||
|
echo json_encode(['ok' => true]);
|
||||||
Reference in New Issue
Block a user