Tier 3: timetracking CRUD + time tracking with topbar timer
This commit is contained in:
2
main.py
2
main.py
@@ -38,6 +38,7 @@ from routers import (
|
||||
decisions as decisions_router,
|
||||
weblinks as weblinks_router,
|
||||
appointments as appointments_router,
|
||||
time_tracking as time_tracking_router,
|
||||
)
|
||||
|
||||
|
||||
@@ -183,3 +184,4 @@ app.include_router(meetings_router.router)
|
||||
app.include_router(decisions_router.router)
|
||||
app.include_router(weblinks_router.router)
|
||||
app.include_router(appointments_router.router)
|
||||
app.include_router(time_tracking_router.router)
|
||||
|
||||
211
routers/time_tracking.py
Normal file
211
routers/time_tracking.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""Time tracking: start/stop timer per task, manual time entries, time log view."""
|
||||
|
||||
from fastapi import APIRouter, Request, Depends, Form, Query
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import RedirectResponse, JSONResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from typing import Optional
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from core.database import get_db
|
||||
from core.sidebar import get_sidebar_data
|
||||
|
||||
router = APIRouter(prefix="/time", tags=["time"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
async def get_running_timer(db: AsyncSession) -> dict | None:
|
||||
"""Get the currently running timer (end_at IS NULL), if any."""
|
||||
result = await db.execute(text("""
|
||||
SELECT te.*, t.title as task_title, t.id as task_id,
|
||||
p.name as project_name, d.name as domain_name
|
||||
FROM time_entries te
|
||||
JOIN tasks t ON te.task_id = t.id
|
||||
LEFT JOIN projects p ON t.project_id = p.id
|
||||
LEFT JOIN domains d ON t.domain_id = d.id
|
||||
WHERE te.end_at IS NULL AND te.is_deleted = false
|
||||
ORDER BY te.start_at DESC
|
||||
LIMIT 1
|
||||
"""))
|
||||
row = result.first()
|
||||
return dict(row._mapping) if row else None
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def time_log(
|
||||
request: Request,
|
||||
task_id: Optional[str] = None,
|
||||
days: int = Query(7, ge=1, le=90),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Time entries log view."""
|
||||
sidebar = await get_sidebar_data(db)
|
||||
running = await get_running_timer(db)
|
||||
|
||||
params = {"days": days}
|
||||
task_filter = ""
|
||||
if task_id:
|
||||
task_filter = "AND te.task_id = :task_id"
|
||||
params["task_id"] = task_id
|
||||
|
||||
result = await db.execute(text(f"""
|
||||
SELECT te.*, t.title as task_title, t.id as task_id,
|
||||
p.name as project_name, d.name as domain_name, d.color as domain_color
|
||||
FROM time_entries te
|
||||
JOIN tasks t ON te.task_id = t.id
|
||||
LEFT JOIN projects p ON t.project_id = p.id
|
||||
LEFT JOIN domains d ON t.domain_id = d.id
|
||||
WHERE te.is_deleted = false
|
||||
AND te.start_at >= CURRENT_DATE - INTERVAL ':days days'
|
||||
{task_filter}
|
||||
ORDER BY te.start_at DESC
|
||||
LIMIT 200
|
||||
""".replace(":days days", f"{days} days")), params)
|
||||
entries = [dict(r._mapping) for r in result]
|
||||
|
||||
# Calculate totals
|
||||
total_minutes = sum(e.get("duration_minutes") or 0 for e in entries)
|
||||
|
||||
# Daily breakdown
|
||||
daily_totals = {}
|
||||
for e in entries:
|
||||
if e.get("start_at"):
|
||||
day = e["start_at"].strftime("%Y-%m-%d")
|
||||
daily_totals[day] = daily_totals.get(day, 0) + (e.get("duration_minutes") or 0)
|
||||
|
||||
return templates.TemplateResponse("time_entries.html", {
|
||||
"request": request,
|
||||
"sidebar": sidebar,
|
||||
"entries": entries,
|
||||
"running": running,
|
||||
"total_minutes": total_minutes,
|
||||
"daily_totals": daily_totals,
|
||||
"days": days,
|
||||
"task_id": task_id or "",
|
||||
"page_title": "Time Log",
|
||||
"active_nav": "time",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/start")
|
||||
async def start_timer(
|
||||
request: Request,
|
||||
task_id: str = Form(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Start a timer for a task. Auto-stops any running timer first."""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Stop any currently running timer
|
||||
running = await get_running_timer(db)
|
||||
if running:
|
||||
duration = int((now - running["start_at"]).total_seconds() / 60)
|
||||
await db.execute(text("""
|
||||
UPDATE time_entries SET end_at = :now, duration_minutes = :dur
|
||||
WHERE id = :id
|
||||
"""), {"now": now, "dur": max(duration, 1), "id": str(running["id"])})
|
||||
|
||||
# Start new timer
|
||||
await db.execute(text("""
|
||||
INSERT INTO time_entries (task_id, start_at, is_deleted, created_at)
|
||||
VALUES (:task_id, :now, false, :now)
|
||||
"""), {"task_id": task_id, "now": now})
|
||||
|
||||
# Redirect back to where they came from
|
||||
referer = request.headers.get("referer", "/tasks")
|
||||
return RedirectResponse(url=referer, status_code=303)
|
||||
|
||||
|
||||
@router.post("/stop")
|
||||
async def stop_timer(
|
||||
request: Request,
|
||||
entry_id: Optional[str] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Stop the running timer (or a specific entry)."""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
if entry_id:
|
||||
# Stop specific entry
|
||||
result = await db.execute(text(
|
||||
"SELECT * FROM time_entries WHERE id = :id AND end_at IS NULL"
|
||||
), {"id": entry_id})
|
||||
entry = result.first()
|
||||
else:
|
||||
# Stop whatever is running
|
||||
result = await db.execute(text(
|
||||
"SELECT * FROM time_entries WHERE end_at IS NULL AND is_deleted = false ORDER BY start_at DESC LIMIT 1"
|
||||
))
|
||||
entry = result.first()
|
||||
|
||||
if entry:
|
||||
entry = dict(entry._mapping)
|
||||
duration = int((now - entry["start_at"]).total_seconds() / 60)
|
||||
await db.execute(text("""
|
||||
UPDATE time_entries SET end_at = :now, duration_minutes = :dur
|
||||
WHERE id = :id
|
||||
"""), {"now": now, "dur": max(duration, 1), "id": str(entry["id"])})
|
||||
|
||||
referer = request.headers.get("referer", "/time")
|
||||
return RedirectResponse(url=referer, status_code=303)
|
||||
|
||||
|
||||
@router.get("/running")
|
||||
async def running_timer_api(db: AsyncSession = Depends(get_db)):
|
||||
"""JSON endpoint for the topbar timer pill to poll."""
|
||||
running = await get_running_timer(db)
|
||||
if not running:
|
||||
return JSONResponse({"running": False})
|
||||
|
||||
elapsed_seconds = int((datetime.now(timezone.utc) - running["start_at"]).total_seconds())
|
||||
return JSONResponse({
|
||||
"running": True,
|
||||
"entry_id": str(running["id"]),
|
||||
"task_id": str(running["task_id"]),
|
||||
"task_title": running["task_title"],
|
||||
"project_name": running.get("project_name"),
|
||||
"start_at": running["start_at"].isoformat(),
|
||||
"elapsed_seconds": elapsed_seconds,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/manual")
|
||||
async def manual_entry(
|
||||
request: Request,
|
||||
task_id: str = Form(...),
|
||||
date: str = Form(...),
|
||||
duration_minutes: int = Form(...),
|
||||
notes: Optional[str] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Add a manual time entry (no start/stop, just duration)."""
|
||||
start_at = f"{date}T12:00:00+00:00"
|
||||
|
||||
await db.execute(text("""
|
||||
INSERT INTO time_entries (task_id, start_at, end_at, duration_minutes, notes, is_deleted, created_at)
|
||||
VALUES (:task_id, :start_at, :start_at, :dur, :notes, false, now())
|
||||
"""), {
|
||||
"task_id": task_id,
|
||||
"start_at": start_at,
|
||||
"dur": duration_minutes,
|
||||
"notes": notes or None,
|
||||
})
|
||||
|
||||
referer = request.headers.get("referer", "/time")
|
||||
return RedirectResponse(url=referer, status_code=303)
|
||||
|
||||
|
||||
@router.post("/{entry_id}/delete")
|
||||
async def delete_entry(
|
||||
request: Request,
|
||||
entry_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
# Direct SQL because time_entries has no updated_at column
|
||||
await db.execute(text("""
|
||||
UPDATE time_entries SET is_deleted = true, deleted_at = now()
|
||||
WHERE id = :id AND is_deleted = false
|
||||
"""), {"id": entry_id})
|
||||
referer = request.headers.get("referer", "/time")
|
||||
return RedirectResponse(url=referer, status_code=303)
|
||||
@@ -168,3 +168,69 @@ function escHtml(s) {
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
|
||||
// ---- Timer Pill (topbar running timer) ----
|
||||
|
||||
let timerStartAt = null;
|
||||
let timerInterval = null;
|
||||
|
||||
function formatElapsed(seconds) {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
if (h > 0) {
|
||||
return h + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0');
|
||||
}
|
||||
return m + ':' + String(s).padStart(2, '0');
|
||||
}
|
||||
|
||||
function updateTimerPill() {
|
||||
if (!timerStartAt) return;
|
||||
const now = new Date();
|
||||
const secs = Math.floor((now - timerStartAt) / 1000);
|
||||
const el = document.getElementById('timer-pill-elapsed');
|
||||
if (el) el.textContent = formatElapsed(secs);
|
||||
}
|
||||
|
||||
async function pollTimer() {
|
||||
try {
|
||||
const resp = await fetch('/time/running');
|
||||
const data = await resp.json();
|
||||
const pill = document.getElementById('timer-pill');
|
||||
if (!pill) return;
|
||||
|
||||
if (data.running) {
|
||||
pill.classList.remove('hidden');
|
||||
timerStartAt = new Date(data.start_at);
|
||||
|
||||
const taskEl = document.getElementById('timer-pill-task');
|
||||
if (taskEl) {
|
||||
taskEl.textContent = data.task_title;
|
||||
taskEl.href = '/tasks/' + data.task_id;
|
||||
}
|
||||
|
||||
updateTimerPill();
|
||||
|
||||
// Start 1s interval if not already running
|
||||
if (!timerInterval) {
|
||||
timerInterval = setInterval(updateTimerPill, 1000);
|
||||
}
|
||||
} else {
|
||||
pill.classList.add('hidden');
|
||||
timerStartAt = null;
|
||||
if (timerInterval) {
|
||||
clearInterval(timerInterval);
|
||||
timerInterval = null;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Silently ignore polling errors
|
||||
}
|
||||
}
|
||||
|
||||
// Poll on load, then every 30s
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
pollTimer();
|
||||
setInterval(pollTimer, 30000);
|
||||
});
|
||||
|
||||
@@ -68,6 +68,10 @@
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
Weblinks
|
||||
</a>
|
||||
<a href="/time" class="nav-item {{ 'active' if active_nav == 'time' }}">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
Time Log
|
||||
</a>
|
||||
<a href="/capture" class="nav-item {{ 'active' if active_nav == 'capture' }}">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>
|
||||
Capture
|
||||
@@ -127,6 +131,15 @@
|
||||
<span class="topbar-env">DEV</span>
|
||||
{% endif %}
|
||||
<div class="topbar-spacer"></div>
|
||||
<!-- Timer Pill (populated by JS) -->
|
||||
<div id="timer-pill" class="timer-pill hidden">
|
||||
<div class="timer-pill-dot"></div>
|
||||
<a id="timer-pill-task" class="timer-pill-task" href="#"></a>
|
||||
<span id="timer-pill-elapsed" class="timer-pill-elapsed">0:00</span>
|
||||
<form method="POST" action="/time/stop" style="display:inline;">
|
||||
<button type="submit" class="timer-pill-stop" title="Stop timer">■</button>
|
||||
</form>
|
||||
</div>
|
||||
<button class="search-trigger" onclick="openSearch()" title="Search (Cmd/K)">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<span>Search...</span>
|
||||
|
||||
155
templates/time_entries.html
Normal file
155
templates/time_entries.html
Normal file
@@ -0,0 +1,155 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">Time Log</h1>
|
||||
<div class="text-muted text-sm mt-1">
|
||||
{{ entries | length }} entries
|
||||
· {{ (total_minutes / 60) | round(1) }}h total
|
||||
(last {{ days }} days)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Running timer banner -->
|
||||
{% if running %}
|
||||
<div class="card mb-3" style="border-color: var(--green); background: var(--green-soft);">
|
||||
<div style="display: flex; align-items: center; gap: 12px;">
|
||||
<div style="width: 10px; height: 10px; border-radius: 50%; background: var(--green); animation: pulse 1.5s infinite;"></div>
|
||||
<div style="flex: 1;">
|
||||
<div style="font-weight: 600; color: var(--text);">
|
||||
Timer running: {{ running.task_title }}
|
||||
</div>
|
||||
{% if running.project_name %}
|
||||
<div class="text-muted text-sm">{{ running.project_name }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="font-size: 1.25rem; font-weight: 700; font-family: var(--font-mono); color: var(--green);"
|
||||
id="running-banner-elapsed" data-start="{{ running.start_at.isoformat() }}">
|
||||
--:--
|
||||
</div>
|
||||
<form method="POST" action="/time/stop">
|
||||
<input type="hidden" name="entry_id" value="{{ running.id }}">
|
||||
<button type="submit" class="btn btn-danger btn-sm">Stop</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters-bar">
|
||||
<a href="/time?days=1" class="btn {{ 'btn-primary' if days == 1 else 'btn-secondary' }} btn-sm">Today</a>
|
||||
<a href="/time?days=7" class="btn {{ 'btn-primary' if days == 7 else 'btn-secondary' }} btn-sm">7 Days</a>
|
||||
<a href="/time?days=30" class="btn {{ 'btn-primary' if days == 30 else 'btn-secondary' }} btn-sm">30 Days</a>
|
||||
<a href="/time?days=90" class="btn {{ 'btn-primary' if days == 90 else 'btn-secondary' }} btn-sm">90 Days</a>
|
||||
</div>
|
||||
|
||||
<!-- Daily totals summary -->
|
||||
{% if daily_totals %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-title mb-2">Daily Summary</div>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 12px;">
|
||||
{% for day, mins in daily_totals | dictsort(reverse=true) %}
|
||||
<div style="text-align: center; padding: 8px 14px; background: var(--surface2); border-radius: var(--radius); min-width: 80px;">
|
||||
<div style="font-size: 1.1rem; font-weight: 700;">{{ (mins / 60) | round(1) }}h</div>
|
||||
<div class="text-muted text-xs">{{ day }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Time entries -->
|
||||
{% if entries %}
|
||||
<div class="card">
|
||||
{% set current_date = namespace(value='') %}
|
||||
{% for entry in entries %}
|
||||
{% set entry_date = entry.start_at.strftime('%A, %B %-d') if entry.start_at else 'Unknown' %}
|
||||
{% if entry_date != current_date.value %}
|
||||
<div style="padding: 10px 12px 4px; font-size: 0.78rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; {% if not loop.first %}border-top: 1px solid var(--border); margin-top: 4px;{% endif %}">
|
||||
{{ entry_date }}
|
||||
</div>
|
||||
{% set current_date.value = entry_date %}
|
||||
{% endif %}
|
||||
|
||||
<div class="list-row">
|
||||
{% if entry.end_at is none %}
|
||||
<div style="width: 10px; height: 10px; border-radius: 50%; background: var(--green); animation: pulse 1.5s infinite; flex-shrink: 0;"></div>
|
||||
{% endif %}
|
||||
<div class="row-title">
|
||||
<a href="/tasks/{{ entry.task_id }}">{{ entry.task_title }}</a>
|
||||
</div>
|
||||
{% if entry.project_name %}
|
||||
<span class="row-tag">{{ entry.project_name }}</span>
|
||||
{% endif %}
|
||||
{% if entry.domain_name %}
|
||||
<span class="row-domain-tag" style="background: {{ entry.domain_color or 'var(--accent)' }}20; color: {{ entry.domain_color or 'var(--accent)' }};">
|
||||
{{ entry.domain_name }}
|
||||
</span>
|
||||
{% endif %}
|
||||
<span style="font-family: var(--font-mono); font-size: 0.82rem; color: var(--text-secondary); min-width: 50px; text-align: right;">
|
||||
{% if entry.end_at is none %}
|
||||
<span style="color: var(--green);">running</span>
|
||||
{% elif entry.duration_minutes %}
|
||||
{% if entry.duration_minutes >= 60 %}
|
||||
{{ (entry.duration_minutes / 60) | int }}h {{ entry.duration_minutes % 60 }}m
|
||||
{% else %}
|
||||
{{ entry.duration_minutes }}m
|
||||
{% endif %}
|
||||
{% else %}
|
||||
--
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="row-meta" style="min-width: 100px; text-align: right;">
|
||||
{% if entry.start_at %}
|
||||
{{ entry.start_at.strftime('%-I:%M %p') }}
|
||||
{% if entry.end_at %}
|
||||
- {{ entry.end_at.strftime('%-I:%M %p') }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</span>
|
||||
<div class="row-actions">
|
||||
<form method="POST" action="/time/{{ entry.id }}/delete" data-confirm="Delete this time entry?">
|
||||
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red);">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">⏱</div>
|
||||
<div class="empty-state-text">No time entries in the last {{ days }} days</div>
|
||||
<div class="text-muted text-sm">Start a timer from any task to begin tracking time</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<style>
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Update running banner elapsed time
|
||||
(function() {
|
||||
const el = document.getElementById('running-banner-elapsed');
|
||||
if (!el) return;
|
||||
const startAt = new Date(el.dataset.start);
|
||||
function update() {
|
||||
const now = new Date();
|
||||
const secs = Math.floor((now - startAt) / 1000);
|
||||
const h = Math.floor(secs / 3600);
|
||||
const m = Math.floor((secs % 3600) / 60);
|
||||
const s = secs % 60;
|
||||
el.textContent = (h > 0 ? h + ':' : '') +
|
||||
String(m).padStart(2, '0') + ':' +
|
||||
String(s).padStart(2, '0');
|
||||
}
|
||||
update();
|
||||
setInterval(update, 1000);
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
50
timer-pill.css
Normal file
50
timer-pill.css
Normal file
@@ -0,0 +1,50 @@
|
||||
|
||||
/* ---- Timer Pill (topbar) ---- */
|
||||
.timer-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 12px;
|
||||
background: var(--green-soft);
|
||||
border: 1px solid var(--green);
|
||||
border-radius: 20px;
|
||||
font-size: 0.82rem;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.timer-pill.hidden { display: none; }
|
||||
.timer-pill-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--green);
|
||||
animation: timer-pulse 1.5s infinite;
|
||||
}
|
||||
@keyframes timer-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
.timer-pill-task {
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.timer-pill-task:hover { color: var(--accent); }
|
||||
.timer-pill-elapsed {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
color: var(--green);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.timer-pill-stop {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--red);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
.timer-pill-stop:hover { color: #fff; }
|
||||
Reference in New Issue
Block a user