Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
320696c57c | ||
|
|
e26c53456f | ||
|
|
1eeb8ad148 | ||
|
|
1d641288e5 | ||
|
|
e58a58e766 | ||
|
|
e0bcda6e7c | ||
|
|
f4419f6a72 | ||
|
|
6de91fa6a9 | ||
|
|
a1a914ee4d |
@@ -6,7 +6,7 @@ import logging
|
|||||||
from baby_monitor.repositories.interfaces import (
|
from baby_monitor.repositories.interfaces import (
|
||||||
TokenRepositoryInterface,
|
TokenRepositoryInterface,
|
||||||
)
|
)
|
||||||
from baby_monitor.repositories.token.memory_token import (
|
from baby_monitor.repositories.token import (
|
||||||
InMemoryTokenRepository,
|
InMemoryTokenRepository,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ def _initialize_token_repository() -> TokenRepositoryInterface:
|
|||||||
) from e
|
) from e
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from baby_monitor.repositories.token.redis_token import (
|
from baby_monitor.repositories.token import (
|
||||||
RedisTokenRepository,
|
RedisTokenRepository,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"""Token repository implementations and configurations."""
|
||||||
|
|
||||||
|
from .default_ttl import DEFAULT_TTL
|
||||||
|
from .memory_token import InMemoryTokenRepository
|
||||||
|
from .redis_token import RedisTokenRepository
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DEFAULT_TTL",
|
||||||
|
"InMemoryTokenRepository",
|
||||||
|
"RedisTokenRepository",
|
||||||
|
]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Definition of Token default TTL."""
|
||||||
|
|
||||||
|
DEFAULT_TTL = 28800 # seconds -> 8 hours
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
from datetime import datetime, timedelta, UTC
|
from datetime import datetime, timedelta, UTC
|
||||||
|
|
||||||
from baby_monitor.repositories.interfaces import TokenRepositoryInterface
|
from baby_monitor.repositories.interfaces import TokenRepositoryInterface
|
||||||
|
from baby_monitor.repositories.token import DEFAULT_TTL
|
||||||
|
|
||||||
|
|
||||||
class InMemoryTokenRepository(TokenRepositoryInterface):
|
class InMemoryTokenRepository(TokenRepositoryInterface):
|
||||||
@@ -16,9 +17,8 @@ class InMemoryTokenRepository(TokenRepositoryInterface):
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
# token -> (user_id, expiry_time)
|
# token -> (user_id, expiry_time)
|
||||||
self._tokens: dict[str, tuple[int, datetime]] = {}
|
self._tokens: dict[str, tuple[int, datetime]] = {}
|
||||||
self._default_ttl = 3600 # Store default TTL for sliding expiration
|
|
||||||
|
|
||||||
def store(self, token: str, user_id: int, ttl: int = 3600) -> None:
|
def store(self, token: str, user_id: int, ttl: int = DEFAULT_TTL) -> None:
|
||||||
"""Store a token with TTL (time to live in seconds)."""
|
"""Store a token with TTL (time to live in seconds)."""
|
||||||
expiry = datetime.now(UTC) + timedelta(seconds=ttl)
|
expiry = datetime.now(UTC) + timedelta(seconds=ttl)
|
||||||
self._tokens[token] = (user_id, expiry)
|
self._tokens[token] = (user_id, expiry)
|
||||||
@@ -39,7 +39,7 @@ class InMemoryTokenRepository(TokenRepositoryInterface):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# Sliding expiration: extend the token lifetime
|
# Sliding expiration: extend the token lifetime
|
||||||
new_expiry = datetime.now(UTC) + timedelta(seconds=self._default_ttl)
|
new_expiry = datetime.now(UTC) + timedelta(seconds=DEFAULT_TTL)
|
||||||
self._tokens[token] = (user_id, new_expiry)
|
self._tokens[token] = (user_id, new_expiry)
|
||||||
|
|
||||||
return user_id
|
return user_id
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from baby_monitor.repositories.interfaces import TokenRepositoryInterface
|
from baby_monitor.repositories.interfaces import TokenRepositoryInterface
|
||||||
|
from baby_monitor.repositories.token import DEFAULT_TTL
|
||||||
|
|
||||||
|
|
||||||
class RedisTokenRepository(TokenRepositoryInterface):
|
class RedisTokenRepository(TokenRepositoryInterface):
|
||||||
"""
|
"""
|
||||||
Redis-based token storage.
|
Redis-based token storage.
|
||||||
|
|
||||||
Requires redis package: pip install redis
|
This implementation uses Redis to store tokens with TTL. Implements sliding expiration.
|
||||||
Set REDIS_URI environment variable (e.g., redis://localhost:6379/0)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, redis_client: Any) -> None:
|
def __init__(self, redis_client: Any) -> None:
|
||||||
@@ -21,9 +21,8 @@ class RedisTokenRepository(TokenRepositoryInterface):
|
|||||||
redis_client: Redis client instance from redis.from_url()
|
redis_client: Redis client instance from redis.from_url()
|
||||||
"""
|
"""
|
||||||
self.redis = redis_client
|
self.redis = redis_client
|
||||||
self.default_ttl = 3600 # Store default TTL for sliding expiration
|
|
||||||
|
|
||||||
def store(self, token: str, user_id: int, ttl: int = 3600) -> None:
|
def store(self, token: str, user_id: int, ttl: int = DEFAULT_TTL) -> None:
|
||||||
"""Store a token with TTL (time to live in seconds)."""
|
"""Store a token with TTL (time to live in seconds)."""
|
||||||
key = f"token:{token}"
|
key = f"token:{token}"
|
||||||
self.redis.setex(key, ttl, str(user_id))
|
self.redis.setex(key, ttl, str(user_id))
|
||||||
@@ -37,7 +36,7 @@ class RedisTokenRepository(TokenRepositoryInterface):
|
|||||||
user_id_str = self.redis.get(key)
|
user_id_str = self.redis.get(key)
|
||||||
if user_id_str:
|
if user_id_str:
|
||||||
# Sliding expiration: extend the token lifetime
|
# Sliding expiration: extend the token lifetime
|
||||||
self.redis.expire(key, self.default_ttl)
|
self.redis.expire(key, DEFAULT_TTL)
|
||||||
return int(user_id_str)
|
return int(user_id_str)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -281,6 +281,9 @@
|
|||||||
let currentDaysRange =
|
let currentDaysRange =
|
||||||
parseInt(localStorage.getItem("lastDiaperTimeRange")) || 7;
|
parseInt(localStorage.getItem("lastDiaperTimeRange")) || 7;
|
||||||
let selectedChildId = null; // null means show first/only child
|
let selectedChildId = null; // null means show first/only child
|
||||||
|
let barChartInstance = null; // Track chart instance for proper cleanup
|
||||||
|
let poopPieChartInstance = null;
|
||||||
|
let peePieChartInstance = null;
|
||||||
|
|
||||||
// Restore last selected child from localStorage
|
// Restore last selected child from localStorage
|
||||||
const lastChildId = localStorage.getItem("lastDiaperChildFilter");
|
const lastChildId = localStorage.getItem("lastDiaperChildFilter");
|
||||||
@@ -782,7 +785,12 @@
|
|||||||
function renderBarChart(chartData) {
|
function renderBarChart(chartData) {
|
||||||
const ctx = document.getElementById("diaperChart").getContext("2d");
|
const ctx = document.getElementById("diaperChart").getContext("2d");
|
||||||
|
|
||||||
new Chart(ctx, {
|
// Destroy existing chart instance if it exists
|
||||||
|
if (barChartInstance) {
|
||||||
|
barChartInstance.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
barChartInstance = new Chart(ctx, {
|
||||||
type: "bar",
|
type: "bar",
|
||||||
data: {
|
data: {
|
||||||
labels: chartData.labels,
|
labels: chartData.labels,
|
||||||
@@ -875,6 +883,11 @@
|
|||||||
function renderPoopPieChart(chartData) {
|
function renderPoopPieChart(chartData) {
|
||||||
const ctx = document.getElementById("poopPieChart").getContext("2d");
|
const ctx = document.getElementById("poopPieChart").getContext("2d");
|
||||||
|
|
||||||
|
// Destroy existing chart instance if it exists
|
||||||
|
if (poopPieChartInstance) {
|
||||||
|
poopPieChartInstance.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
const colors = {
|
const colors = {
|
||||||
Light: {
|
Light: {
|
||||||
bg: "rgba(139, 69, 19, 0.5)",
|
bg: "rgba(139, 69, 19, 0.5)",
|
||||||
@@ -901,7 +914,7 @@
|
|||||||
(label) => colors[label]?.border || "rgba(200, 200, 200, 1)",
|
(label) => colors[label]?.border || "rgba(200, 200, 200, 1)",
|
||||||
);
|
);
|
||||||
|
|
||||||
new Chart(ctx, {
|
poopPieChartInstance = new Chart(ctx, {
|
||||||
type: "pie",
|
type: "pie",
|
||||||
data: {
|
data: {
|
||||||
labels: chartData.labels,
|
labels: chartData.labels,
|
||||||
@@ -957,6 +970,11 @@
|
|||||||
function renderPeePieChart(chartData) {
|
function renderPeePieChart(chartData) {
|
||||||
const ctx = document.getElementById("peePieChart").getContext("2d");
|
const ctx = document.getElementById("peePieChart").getContext("2d");
|
||||||
|
|
||||||
|
// Destroy existing chart instance if it exists
|
||||||
|
if (peePieChartInstance) {
|
||||||
|
peePieChartInstance.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
const colors = {
|
const colors = {
|
||||||
Light: {
|
Light: {
|
||||||
bg: "rgba(255, 215, 0, 0.5)",
|
bg: "rgba(255, 215, 0, 0.5)",
|
||||||
@@ -983,7 +1001,7 @@
|
|||||||
(label) => colors[label]?.border || "rgba(200, 200, 200, 1)",
|
(label) => colors[label]?.border || "rgba(200, 200, 200, 1)",
|
||||||
);
|
);
|
||||||
|
|
||||||
new Chart(ctx, {
|
peePieChartInstance = new Chart(ctx, {
|
||||||
type: "pie",
|
type: "pie",
|
||||||
data: {
|
data: {
|
||||||
labels: chartData.labels,
|
labels: chartData.labels,
|
||||||
|
|||||||
@@ -319,6 +319,8 @@
|
|||||||
parseInt(localStorage.getItem("lastFeedingTimeRange")) || 7;
|
parseInt(localStorage.getItem("lastFeedingTimeRange")) || 7;
|
||||||
let selectedChildId = null; // null means show first/only child
|
let selectedChildId = null; // null means show first/only child
|
||||||
let barChartInstance = null;
|
let barChartInstance = null;
|
||||||
|
let pieChartInstance = null;
|
||||||
|
let durationChartInstance = null;
|
||||||
|
|
||||||
// Restore last selected child from localStorage
|
// Restore last selected child from localStorage
|
||||||
const lastChildId = localStorage.getItem("lastFeedingChildFilter");
|
const lastChildId = localStorage.getItem("lastFeedingChildFilter");
|
||||||
@@ -979,6 +981,11 @@
|
|||||||
function renderPieChart(chartData) {
|
function renderPieChart(chartData) {
|
||||||
const ctx = document.getElementById("pieChart").getContext("2d");
|
const ctx = document.getElementById("pieChart").getContext("2d");
|
||||||
|
|
||||||
|
// Destroy existing chart instance if it exists
|
||||||
|
if (pieChartInstance) {
|
||||||
|
pieChartInstance.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
// Color mapping for each type
|
// Color mapping for each type
|
||||||
const colorMap = {
|
const colorMap = {
|
||||||
"Left Breast": {
|
"Left Breast": {
|
||||||
@@ -1003,7 +1010,7 @@
|
|||||||
(label) => colorMap[label]?.border || "rgba(200, 200, 200, 1)",
|
(label) => colorMap[label]?.border || "rgba(200, 200, 200, 1)",
|
||||||
);
|
);
|
||||||
|
|
||||||
new Chart(ctx, {
|
pieChartInstance = new Chart(ctx, {
|
||||||
type: "pie",
|
type: "pie",
|
||||||
data: {
|
data: {
|
||||||
labels: chartData.labels,
|
labels: chartData.labels,
|
||||||
@@ -1059,7 +1066,12 @@
|
|||||||
function renderDurationChart(chartData) {
|
function renderDurationChart(chartData) {
|
||||||
const ctx = document.getElementById("durationChart").getContext("2d");
|
const ctx = document.getElementById("durationChart").getContext("2d");
|
||||||
|
|
||||||
new Chart(ctx, {
|
// Destroy existing chart instance if it exists
|
||||||
|
if (durationChartInstance) {
|
||||||
|
durationChartInstance.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
durationChartInstance = new Chart(ctx, {
|
||||||
type: "bar",
|
type: "bar",
|
||||||
data: {
|
data: {
|
||||||
labels: chartData.labels,
|
labels: chartData.labels,
|
||||||
|
|||||||
@@ -167,6 +167,52 @@
|
|||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
.recent-activity {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.recent-activity h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #333;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
.activity-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.activity-card {
|
||||||
|
background: white;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.activity-card .icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.activity-card .type {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.activity-card .time {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.activity-card .details {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.activity-card.empty {
|
||||||
|
opacity: 0.5;
|
||||||
|
font-style: italic;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -321,6 +367,7 @@
|
|||||||
let activeSleep = null;
|
let activeSleep = null;
|
||||||
let lastSleep = null;
|
let lastSleep = null;
|
||||||
let sleepUpdateTimerInterval = null;
|
let sleepUpdateTimerInterval = null;
|
||||||
|
let lastDiaperChange = null;
|
||||||
|
|
||||||
async function loadFeedingStatus(children) {
|
async function loadFeedingStatus(children) {
|
||||||
userChildren = children;
|
userChildren = children;
|
||||||
@@ -376,6 +423,20 @@
|
|||||||
lastSleep = sleeps[0];
|
lastSleep = sleeps[0];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch all diaper changes to get the last one
|
||||||
|
const diaperResponse = await fetch("/api/diaper-changes", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (diaperResponse.ok) {
|
||||||
|
const diapers = await diaperResponse.json();
|
||||||
|
if (diapers.length > 0) {
|
||||||
|
lastDiaperChange = diapers[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading feeding status:", error);
|
console.error("Error loading feeding status:", error);
|
||||||
}
|
}
|
||||||
@@ -478,9 +539,98 @@
|
|||||||
</button>
|
</button>
|
||||||
${feedingButtonHtml}
|
${feedingButtonHtml}
|
||||||
${sleepButtonHtml}
|
${sleepButtonHtml}
|
||||||
|
${buildRecentActivitySection()}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildRecentActivitySection() {
|
||||||
|
// Build diaper card
|
||||||
|
const diaperCard = lastDiaperChange
|
||||||
|
? `
|
||||||
|
<div class="activity-card">
|
||||||
|
<div class="icon">🧷</div>
|
||||||
|
<div class="type">Last Diaper</div>
|
||||||
|
<div class="time">${formatTime(lastDiaperChange.change_time)}</div>
|
||||||
|
<div class="details">
|
||||||
|
${lastDiaperChange.poop_amount ? `💩 ${lastDiaperChange.poop_amount}` : ""}
|
||||||
|
${lastDiaperChange.poop_amount && lastDiaperChange.pee_amount ? " • " : ""}
|
||||||
|
${lastDiaperChange.pee_amount ? `💧 ${lastDiaperChange.pee_amount}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: `
|
||||||
|
<div class="activity-card empty">
|
||||||
|
<div class="icon">🧷</div>
|
||||||
|
<div class="type">No diaper changes yet</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Build feeding card
|
||||||
|
const feedingCard = lastFeeding
|
||||||
|
? `
|
||||||
|
<div class="activity-card">
|
||||||
|
<div class="icon">🍼</div>
|
||||||
|
<div class="type">Last Feeding</div>
|
||||||
|
<div class="time">${formatTime(lastFeeding.start_time)}</div>
|
||||||
|
<div class="details">
|
||||||
|
${formatFeedingType(lastFeeding.feeding_type)}
|
||||||
|
${lastFeeding.end_time ? ` • ${calculateDuration(lastFeeding.start_time, lastFeeding.end_time)}` : " (ongoing)"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: `
|
||||||
|
<div class="activity-card empty">
|
||||||
|
<div class="icon">🍼</div>
|
||||||
|
<div class="type">No feedings yet</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Build sleep card
|
||||||
|
const sleepCard = lastSleep
|
||||||
|
? `
|
||||||
|
<div class="activity-card">
|
||||||
|
<div class="icon">😴</div>
|
||||||
|
<div class="type">Last Sleep</div>
|
||||||
|
<div class="time">${formatTime(lastSleep.start_time)}</div>
|
||||||
|
<div class="details">
|
||||||
|
${lastSleep.end_time ? calculateDuration(lastSleep.start_time, lastSleep.end_time) : "ongoing"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: `
|
||||||
|
<div class="activity-card empty">
|
||||||
|
<div class="icon">😴</div>
|
||||||
|
<div class="type">No sleep sessions yet</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="recent-activity">
|
||||||
|
<h3>📊 Recent Activity</h3>
|
||||||
|
<div class="activity-grid">
|
||||||
|
${diaperCard}
|
||||||
|
${feedingCard}
|
||||||
|
${sleepCard}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateDuration(startTime, endTime) {
|
||||||
|
const start = new Date(startTime);
|
||||||
|
const end = new Date(endTime);
|
||||||
|
const diffMs = end - start;
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
|
||||||
|
if (diffMins < 60) {
|
||||||
|
return `${diffMins} min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = Math.floor(diffMins / 60);
|
||||||
|
const mins = diffMins % 60;
|
||||||
|
return `${hours}h ${mins}m`;
|
||||||
|
}
|
||||||
|
|
||||||
function formatFeedingType(type) {
|
function formatFeedingType(type) {
|
||||||
const types = {
|
const types = {
|
||||||
left_breast: "Left Breast",
|
left_breast: "Left Breast",
|
||||||
|
|||||||
@@ -345,13 +345,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document
|
function attachTimeRangeListener() {
|
||||||
.getElementById("timeRangeFilter")
|
const timeRangeFilter = document.getElementById("timeRangeFilter");
|
||||||
.addEventListener("change", function () {
|
if (timeRangeFilter) {
|
||||||
|
timeRangeFilter.addEventListener("change", function () {
|
||||||
// Save selection to localStorage
|
// Save selection to localStorage
|
||||||
localStorage.setItem("lastSleepTimeRange", this.value);
|
localStorage.setItem("lastSleepTimeRange", this.value);
|
||||||
loadData();
|
renderCharts();
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
try {
|
try {
|
||||||
@@ -443,6 +446,9 @@
|
|||||||
attachChildFilterListener();
|
attachChildFilterListener();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always attach time range listener after recreating the controls
|
||||||
|
attachTimeRangeListener();
|
||||||
|
|
||||||
renderCharts();
|
renderCharts();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading data:", error);
|
console.error("Error loading data:", error);
|
||||||
|
|||||||
Reference in New Issue
Block a user