Skip to content

Commit 80f084c

Browse files
add history
1 parent d1eff17 commit 80f084c

File tree

5 files changed

+263
-50
lines changed

5 files changed

+263
-50
lines changed

app/pages/admin/blog/index.vue

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,6 @@
2121
<p v-if="createError" class="text-red-500 mt-2">{{ createError }}</p>
2222
<p v-if="createOk" class="text-green-600 mt-2">Created</p>
2323
</div>
24-
25-
2624
</div>
2725

2826
<div class="mt-10 bg-fill-secondary border border-separator-primary rounded-xl p-4">

app/pages/electricity/[slug].vue

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
<template>
2+
<div
3+
class="min-h-screen text-label-primary container flex flex-col items-center justify-start mx-auto px-4 py-8 max-w-5xl">
4+
<div class="w-full mb-6">
5+
<NuxtLink to="/electricity" class="text-accent-primary hover:underline text-sm">← Back to trackers
6+
</NuxtLink>
7+
</div>
8+
9+
<h1 class="text-3xl font-bold text-accent-primary mb-6">
10+
{{ data?.tracker.name }}
11+
</h1>
12+
13+
<div class="w-full grid gap-4 md:grid-cols-3">
14+
<div class="bg-fill-secondary p-4 rounded-xl border border-separator-primary md:col-span-2">
15+
<div class="flex items-center gap-3 mb-2">
16+
<span :class="[
17+
'inline-flex items-center gap-2 text-sm font-semibold px-3 py-1 rounded-full',
18+
currentStatus === 'online'
19+
? 'bg-green-500/10 text-green-400 border border-green-500/30'
20+
: 'bg-red-500/10 text-red-400 border border-red-500/30'
21+
]">
22+
<span :class="[
23+
'inline-block w-2 h-2 rounded-full',
24+
currentStatus === 'online' ? 'bg-green-400' : 'bg-red-400'
25+
]" />
26+
{{ currentStatus === 'online' ? 'Online' : 'Offline' }}
27+
</span>
28+
</div>
29+
<p class="text-sm text-label-secondary">
30+
Last seen: {{ formatDate(data?.current.lastAliveAt) }}
31+
</p>
32+
<p class="text-sm text-label-secondary">
33+
Since: {{ formatDate(data?.current.since) }}
34+
</p>
35+
</div>
36+
37+
<div class="bg-fill-secondary p-4 rounded-xl border border-separator-primary">
38+
<p class="text-sm text-label-secondary">Slug</p>
39+
<p class="font-mono text-sm">{{ data?.tracker.slug }}</p>
40+
<p class="text-sm text-label-secondary mt-3">Created</p>
41+
<p class="text-sm">{{ formatDate(data?.tracker.createdAt) }}</p>
42+
</div>
43+
</div>
44+
45+
<div class="w-full mt-8 bg-fill-secondary p-4 rounded-xl border border-separator-primary">
46+
<h2 class="text-xl font-semibold mb-4">Status changes (last week)</h2>
47+
48+
<div v-if="data?.history?.length" class="space-y-3">
49+
<div v-for="(h, idx) in data.history" :key="idx"
50+
class="flex items-center justify-between p-3 rounded-lg border border-separator-primary bg-fill-primary">
51+
<div class="flex items-center gap-2">
52+
<span :class="[
53+
'inline-block w-2 h-2 rounded-full',
54+
h.status === 'online' ? 'bg-green-400' : 'bg-red-400'
55+
]" />
56+
<span class="capitalize">{{ h.status }}</span>
57+
</div>
58+
<div class="text-sm text-label-secondary">
59+
<span>{{ formatDate(h.start) }}</span>
60+
<span> → </span>
61+
<span>{{ h.end ? formatDate(h.end) : 'now' }}</span>
62+
</div>
63+
</div>
64+
</div>
65+
66+
<div v-else class="text-sm text-label-secondary">No activity in the last week.</div>
67+
</div>
68+
</div>
69+
</template>
70+
71+
<script setup lang="ts">
72+
import { definePageMeta, useFetch, useRoute, computed } from '#imports'
73+
74+
definePageMeta({
75+
layout: 'default',
76+
})
77+
78+
type Status = 'online' | 'offline'
79+
80+
type ApiResponse = {
81+
tracker: { name: string; slug: string; createdAt: string }
82+
current: { status: Status; lastAliveAt?: string; since?: string }
83+
history: { status: Status; start: string; end: string | null }[]
84+
}
85+
86+
const route = useRoute()
87+
const slug = route.params.slug as string
88+
89+
const { data } = await useFetch<ApiResponse>(`/api/electricty_tracker/${slug}/history`)
90+
91+
const currentStatus = computed<Status>(() => (data.value?.current.status ?? 'offline'))
92+
93+
const formatDate = (date?: string) => {
94+
if (!date) return ''
95+
return new Date(date).toLocaleString('en-US', {
96+
year: 'numeric',
97+
month: 'short',
98+
day: '2-digit',
99+
hour: '2-digit',
100+
minute: '2-digit',
101+
hour12: false,
102+
})
103+
}
104+
</script>

app/pages/electricity/index.vue

Lines changed: 16 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,46 @@
11
<template>
22
<div
3-
class="min-h-screen text-label-primary container flex flex-col items-center justify-start mx-auto px-4 py-8 max-w-6xl"
4-
>
3+
class="min-h-screen text-label-primary container flex flex-col items-center justify-start mx-auto px-4 py-8 max-w-6xl">
54
<h1 class="text-4xl font-bold text-accent-primary mb-8">Electricity</h1>
65

7-
<div
8-
v-if="data?.trackers"
9-
class="space-y-4 w-full"
10-
>
11-
<div
12-
v-for="tracker in data.trackers"
13-
:key="tracker.slug"
14-
class="bg-fill-secondary p-4 rounded-xl border border-separator-primary"
15-
>
6+
<div v-if="data?.trackers" class="space-y-4 w-full">
7+
<NuxtLink v-for="tracker in data.trackers" :key="tracker.slug" :to="`/electricity/${tracker.slug}`"
8+
class="block bg-fill-secondary p-4 rounded-xl border border-separator-primary hover:border-accent-primary transition-colors">
169
<h3 class="text-lg font-semibold text-label-primary mb-2">
1710
{{ tracker.name }}
1811
</h3>
1912
<p class="text-sm text-label-secondary">
2013
Останній раз активний:
2114
{{ formatDate(tracker.lastAlive) }}
2215
</p>
23-
</div>
16+
</NuxtLink>
2417
</div>
2518

26-
<div
27-
class="my-8 bg-fill-secondary p-4 rounded-xl border border-separator-primary w-full"
28-
>
19+
<div class="my-8 bg-fill-secondary p-4 rounded-xl border border-separator-primary w-full">
2920
<div class="flex gap-4">
30-
<input
31-
v-model="newTrackerName"
32-
type="text"
33-
placeholder="Enter tracker name"
21+
<input v-model="newTrackerName" type="text" placeholder="Enter tracker name"
3422
class="flex-1 px-4 py-2 bg-fill-primary border border-separator-primary rounded-lg text-label-primary focus:outline-none focus:border-accent-primary"
35-
@keyup.enter="addTracker"
36-
/>
37-
<MainButton
38-
@click="addTracker"
39-
:label="isAdding ? 'Adding...' : 'Add'"
40-
button-style="primary"
41-
/>
23+
@keyup.enter="addTracker" />
24+
<MainButton @click="addTracker" :label="isAdding ? 'Adding...' : 'Add'" button-style="primary" />
4225
</div>
4326
</div>
4427

45-
<div
46-
v-if="showTokenDialog"
47-
class="flex flex-col items-center justify-center w-full"
48-
>
49-
<div
50-
class="bg-fill-secondary rounded-xl border border-separator-primary p-6 max-w-2xl w-full mx-4 backdrop-blur-sm"
51-
@click.stop
52-
>
28+
<div v-if="showTokenDialog" class="flex flex-col items-center justify-center w-full">
29+
<div class="bg-fill-secondary rounded-xl border border-separator-primary p-6 max-w-2xl w-full mx-4 backdrop-blur-sm"
30+
@click.stop>
5331
<h3 class="text-xl font-bold text-label-primary mb-4">
5432
Tracker Token
5533
</h3>
56-
<div
57-
class="bg-red-500/10 border border-red-500/30 rounded-lg p-4 mb-4"
58-
>
34+
<div class="bg-red-500/10 border border-red-500/30 rounded-lg p-4 mb-4">
5935
<p class="text-red-500 text-sm font-semibold">
6036
⚠️ Це остання можливість скопіювати токен, зробіть це
6137
перед тим як виконати будь яку іншу дію
6238
</p>
6339
</div>
6440
<div class="flex gap-2 mb-4">
65-
<input
66-
:value="newTrackerToken"
67-
readonly
68-
class="flex-1 px-4 py-2 bg-fill-primary border border-separator-primary rounded-lg text-label-primary text-sm font-mono"
69-
/>
70-
<MainButton
71-
@click="copyToken"
72-
label="Copy"
73-
button-style="primary"
74-
icon="mdi:content-copy"
75-
/>
41+
<input :value="newTrackerToken" readonly
42+
class="flex-1 px-4 py-2 bg-fill-primary border border-separator-primary rounded-lg text-label-primary text-sm font-mono" />
43+
<MainButton @click="copyToken" label="Copy" button-style="primary" icon="mdi:content-copy" />
7644
</div>
7745
</div>
7846
</div>
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { defineEventHandler, getRouterParam, createError, useNitroApp } from '#imports'
2+
3+
type Status = 'online' | 'offline'
4+
5+
const TEN_MIN_MS = 10 * 60 * 1000
6+
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000
7+
8+
export default defineEventHandler(async (event) => {
9+
const user = event.context.user
10+
if (!user) {
11+
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
12+
}
13+
14+
const slug = getRouterParam(event, 'slug')
15+
if (!slug) {
16+
throw createError({ statusCode: 400, statusMessage: 'Missing slug' })
17+
}
18+
19+
const db = useNitroApp().db
20+
21+
const tracker = await db.collection('electricity_trackers').findOne({ slug, userId: user.userId })
22+
if (!tracker) {
23+
throw createError({ statusCode: 404, statusMessage: 'Tracker not found' })
24+
}
25+
26+
const now = new Date()
27+
const weekStart = new Date(now.getTime() - SEVEN_DAYS_MS)
28+
29+
const preWindowPing = await db
30+
.collection('electricity_tracker_alive')
31+
.find({ deviceSlug: slug, timestamp: { $lt: weekStart } })
32+
.sort({ timestamp: -1 })
33+
.limit(1)
34+
.toArray()
35+
36+
const pingsInWindow = await db
37+
.collection('electricity_tracker_alive')
38+
.find({ deviceSlug: slug, timestamp: { $gte: weekStart, $lte: now } })
39+
.sort({ timestamp: 1 })
40+
.toArray()
41+
42+
const timestamps: Date[] = pingsInWindow.map((d: any) => d.timestamp as Date)
43+
const lastPingDoc = await db
44+
.collection('electricity_tracker_alive')
45+
.find({ deviceSlug: slug })
46+
.sort({ timestamp: -1 })
47+
.limit(1)
48+
.toArray()
49+
50+
const lastAliveAt: Date | undefined = lastPingDoc.length ? (lastPingDoc[0].timestamp as Date) : undefined
51+
const current: { status: Status; lastAliveAt?: Date; since?: Date } = {
52+
status: lastAliveAt && now.getTime() - lastAliveAt.getTime() <= TEN_MIN_MS ? 'online' : 'offline',
53+
lastAliveAt,
54+
since: undefined,
55+
}
56+
57+
type Interval = { status: Status; start: Date; end: Date | null }
58+
59+
const history: Interval[] = []
60+
61+
let currentState: Status
62+
let currentStart: Date
63+
const firstInWindow = timestamps[0]
64+
65+
if (firstInWindow) {
66+
const pre = preWindowPing[0]?.timestamp as Date | undefined
67+
const gapToStart = pre ? firstInWindow.getTime() - pre.getTime() : Infinity
68+
// If there was a ping shortly before the first in-window ping, consider the device online at week start
69+
if (pre && (weekStart.getTime() - pre.getTime() <= TEN_MIN_MS || gapToStart <= TEN_MIN_MS)) {
70+
currentState = 'online'
71+
} else {
72+
currentState = 'offline'
73+
}
74+
} else {
75+
// No pings in window
76+
const pre = preWindowPing[0]?.timestamp as Date | undefined
77+
if (pre && weekStart.getTime() - pre.getTime() <= TEN_MIN_MS) {
78+
currentState = 'online'
79+
} else {
80+
currentState = 'offline'
81+
}
82+
}
83+
currentStart = new Date(weekStart)
84+
85+
let prevPing: Date | undefined
86+
for (const ping of timestamps) {
87+
if (!prevPing) {
88+
if (currentState === 'offline') {
89+
// Transition to online at first ping
90+
history.push({ status: 'offline', start: currentStart, end: ping })
91+
currentState = 'online'
92+
currentStart = ping
93+
} else {
94+
// Already online; keep session from week start
95+
// no interval push here
96+
}
97+
prevPing = ping
98+
continue
99+
}
100+
101+
const gap = ping.getTime() - prevPing.getTime()
102+
if (gap > TEN_MIN_MS) {
103+
if (currentState === 'online') {
104+
history.push({ status: 'online', start: currentStart, end: prevPing })
105+
currentState = 'offline'
106+
currentStart = prevPing
107+
}
108+
// We are offline until this ping
109+
history.push({ status: 'offline', start: currentStart, end: ping })
110+
currentState = 'online'
111+
currentStart = ping
112+
}
113+
prevPing = ping
114+
}
115+
116+
// Close the current interval to now (end null for current state)
117+
history.push({ status: currentState, start: currentStart, end: null })
118+
119+
// Determine current.since from the last interval
120+
const lastInterval = history[history.length - 1]
121+
current.since = lastInterval.start
122+
123+
return {
124+
tracker: {
125+
name: tracker.name,
126+
slug: tracker.slug,
127+
createdAt: tracker.createdAt,
128+
},
129+
current: {
130+
status: current.status,
131+
lastAliveAt: current.lastAliveAt,
132+
since: current.since,
133+
},
134+
history: history,
135+
}
136+
})
137+
138+

server/plugins/2_mongo.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,11 @@ async function createIndexes(db: Db, logger: any) {
6868
}
6969
)
7070

71+
await db.collection('electricity_tracker_alive').createIndex(
72+
{ deviceSlug: 1, timestamp: -1 },
73+
{ name: 'deviceSlug_timestamp_desc' }
74+
)
75+
7176
const blogPosts = db.collection('blogPosts')
7277
await blogPosts.createIndex(
7378
{ slug: 1 },

0 commit comments

Comments
 (0)