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="stylesheet" href="./assets/about.css">
|
||||
<link rel="stylesheet" href="./assets/about.css?v=15a">
|
||||
|
||||
<!-- Structured data for AEO / AI search -->
|
||||
<script type="application/ld+json">
|
||||
|
||||
@@ -36,6 +36,16 @@ import { useColumnPopup } from './useColumnPopup.js';
|
||||
import { useTableScroll } from './useTableScroll.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() {
|
||||
|
||||
// ── 1. LOCATION ──────────────────────────────────────────────────────
|
||||
@@ -66,6 +76,8 @@ export function useAppState() {
|
||||
|
||||
const { forecast, airQuality, loading, error, now, fetchedAt, liveElev } = useForecast(location, isPro);
|
||||
|
||||
useEffect(() => { track('visit'); }, []);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
@@ -150,6 +162,7 @@ export function useAppState() {
|
||||
const toggleCol = (col) => setVisibleCols(prev => ({ ...prev, [col]: !prev[col] }));
|
||||
|
||||
const activateProfile = (key) => {
|
||||
track('profile', key);
|
||||
const profile = FILTER_PROFILES[key];
|
||||
try { localStorage.setItem('sunscope_profile', key); } catch (e) { /* ignore */ }
|
||||
setActiveProfile(key);
|
||||
|
||||
@@ -224,7 +224,7 @@ async function build() {
|
||||
|
||||
// 8. Copy 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);
|
||||
if (fs.existsSync(src)) {
|
||||
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:image" content="https://sunscope.net/og-image.png" />
|
||||
<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">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
|
||||
+2
-2
@@ -70,10 +70,10 @@
|
||||
|
||||
<link rel="preconnect" href="https://api.open-meteo.com" crossorigin />
|
||||
<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>
|
||||
<body>
|
||||
<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>
|
||||
</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