First commit
This commit is contained in:
2
.env
Normal file
2
.env
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
MONGODB_URI=mongodb+srv://saturn:taCexQWvuSGKg8hCbh%25@mycluster.my1emqo.mongodb.net/?appName=MyCluster
|
||||||
|
MONGODB_DB=dailyform
|
||||||
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
MONGODB_URI=mongodb+srv://<user>:<password>@<cluster>.mongodb.net/?retryWrites=true&w=majority
|
||||||
|
MONGODB_DB=dailyform
|
||||||
7
Dockerfile
Normal file
7
Dockerfile
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY . .
|
||||||
|
EXPOSE 5000
|
||||||
|
CMD ["python", "app.py"]
|
||||||
69
README.md
Normal file
69
README.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# Daily Form
|
||||||
|
|
||||||
|
Shared daily check-in form. Answers are global (no per-session state). Auto-snapshots to MongoDB at 10:00 AM Riga time.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### 1. MongoDB Atlas (free)
|
||||||
|
|
||||||
|
1. Go to https://cloud.mongodb.com → create account → create a free M0 cluster
|
||||||
|
2. Create a database user (Database Access → Add New User)
|
||||||
|
3. Allow access from anywhere (Network Access → Add IP Address → `0.0.0.0/0`)
|
||||||
|
4. Get connection string (Connect → Drivers) — looks like:
|
||||||
|
`mongodb+srv://user:pass@cluster.mongodb.net/`
|
||||||
|
|
||||||
|
### 2. Configure
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env and paste your MongoDB URI
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Run on saturn
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy files to saturn
|
||||||
|
scp -r . armandins@saturn:/opt/dailyform
|
||||||
|
|
||||||
|
# SSH in
|
||||||
|
ssh armandins@saturn
|
||||||
|
cd /opt/dailyform
|
||||||
|
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add to Nginx Proxy Manager pointing to port 5000.
|
||||||
|
|
||||||
|
### 4. Editing questions
|
||||||
|
|
||||||
|
Edit `questions.yaml` and restart the container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose restart
|
||||||
|
```
|
||||||
|
|
||||||
|
The `questions.yaml` is mounted as a read-only volume so you edit it on the host, no rebuild needed.
|
||||||
|
|
||||||
|
## Question types
|
||||||
|
|
||||||
|
| Type | Description | Extra fields |
|
||||||
|
|---|---|---|
|
||||||
|
| `yesno` | Yes / No buttons | — |
|
||||||
|
| `scale` | Slider | `min`, `max`, `step` |
|
||||||
|
| `number` | Number input | `min`, `max` |
|
||||||
|
| `duration` | H : MM input (stored as minutes) | — |
|
||||||
|
| `counter` | Big − / + buttons | `min`, `max`, `step` |
|
||||||
|
| `text` | Single line | — |
|
||||||
|
| `textarea` | Multi-line | — |
|
||||||
|
| `choice` | Single select pills | `options: [...]` |
|
||||||
|
| `multichoice` | Multi select pills | `options: [...]` |
|
||||||
|
|
||||||
|
## Snapshots
|
||||||
|
|
||||||
|
Snapshots are stored in the `snapshots` MongoDB collection. View last 30:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/snapshots
|
||||||
|
```
|
||||||
|
|
||||||
|
Or browse directly in MongoDB Atlas UI → Collections → `dailyform` → `snapshots`.
|
||||||
131
app.py
Normal file
131
app.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import os
|
||||||
|
import yaml
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
from flask import Flask, jsonify, request, render_template, Response
|
||||||
|
from pymongo import MongoClient
|
||||||
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
RIGA = ZoneInfo("Europe/Riga")
|
||||||
|
|
||||||
|
# MongoDB
|
||||||
|
client = MongoClient(os.environ["MONGODB_URI"])
|
||||||
|
db = client[os.environ.get("MONGODB_DB", "dailyform")]
|
||||||
|
answers_col = db["current_answers"]
|
||||||
|
snapshots_col = db["snapshots"]
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
with open("questions.yaml", "r") as f:
|
||||||
|
return yaml.safe_load(f)
|
||||||
|
|
||||||
|
def get_questions():
|
||||||
|
return load_config().get("questions", [])
|
||||||
|
|
||||||
|
def get_current_period():
|
||||||
|
now = datetime.now(RIGA)
|
||||||
|
today_10 = now.replace(hour=10, minute=0, second=0, microsecond=0)
|
||||||
|
if now < today_10:
|
||||||
|
start = today_10 - timedelta(days=1)
|
||||||
|
end = today_10
|
||||||
|
else:
|
||||||
|
start = today_10
|
||||||
|
end = today_10 + timedelta(days=1)
|
||||||
|
return {"start": start.isoformat(), "end": end.isoformat()}
|
||||||
|
|
||||||
|
def take_snapshot():
|
||||||
|
now = datetime.now(RIGA)
|
||||||
|
doc = answers_col.find_one({}, {"_id": 0}) or {}
|
||||||
|
date_str = now.strftime("%Y-%m-%d")
|
||||||
|
period = get_current_period()
|
||||||
|
for_date = datetime.fromisoformat(period["start"]).strftime("%Y-%m-%d")
|
||||||
|
snapshots_col.insert_one({
|
||||||
|
"timestamp": now.isoformat(),
|
||||||
|
"date": date_str,
|
||||||
|
"for_date": for_date,
|
||||||
|
"answers": doc,
|
||||||
|
})
|
||||||
|
answers_col.replace_one({}, {}, upsert=True)
|
||||||
|
app.logger.info(f"Snapshot taken: {date_str}, for_date: {for_date}")
|
||||||
|
|
||||||
|
scheduler = BackgroundScheduler(timezone=RIGA)
|
||||||
|
scheduler.add_job(take_snapshot, "cron", hour=10, minute=0)
|
||||||
|
scheduler.start()
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
return render_template("index.html", questions=get_questions(), period=get_current_period())
|
||||||
|
|
||||||
|
@app.route("/history")
|
||||||
|
def history():
|
||||||
|
return render_template("history.html", questions=get_questions())
|
||||||
|
|
||||||
|
@app.route("/api/answers", methods=["GET"])
|
||||||
|
def get_answers():
|
||||||
|
doc = answers_col.find_one({}, {"_id": 0}) or {}
|
||||||
|
return jsonify(doc)
|
||||||
|
|
||||||
|
@app.route("/api/answers", methods=["POST"])
|
||||||
|
def save_answers():
|
||||||
|
data = request.get_json()
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return jsonify({"error": "invalid"}), 400
|
||||||
|
answers_col.replace_one({}, data, upsert=True)
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
@app.route("/api/snapshots")
|
||||||
|
def get_snapshots():
|
||||||
|
limit = int(request.args.get("limit", 365))
|
||||||
|
docs = list(snapshots_col.find({}, {"_id": 0}).sort("timestamp", -1).limit(limit))
|
||||||
|
return jsonify(docs)
|
||||||
|
|
||||||
|
@app.route("/api/snapshots/<date_str>/md")
|
||||||
|
def snapshot_as_md(date_str):
|
||||||
|
snap = snapshots_col.find_one({"date": date_str}, {"_id": 0})
|
||||||
|
if not snap:
|
||||||
|
return "Not found", 404
|
||||||
|
questions = get_questions()
|
||||||
|
answers = snap.get("answers", {})
|
||||||
|
|
||||||
|
lines = [f"# Daily Check-in — {date_str}", ""]
|
||||||
|
lines.append(f"_Snapshot: {snap.get('timestamp', '?')}_")
|
||||||
|
if answers.get("__done__"):
|
||||||
|
done_at = answers.get("__done_at__", "")
|
||||||
|
try:
|
||||||
|
done_at = datetime.fromisoformat(done_at).strftime("%H:%M")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
lines.append(f"_Marked as filled at: {done_at}_")
|
||||||
|
lines += ["", "---", ""]
|
||||||
|
|
||||||
|
for q in questions:
|
||||||
|
val = answers.get(q["id"])
|
||||||
|
lines.append(f"## {q['label']}")
|
||||||
|
lines.append("")
|
||||||
|
if val is None:
|
||||||
|
lines.append("_(no answer)_")
|
||||||
|
elif q["type"] == "yesno":
|
||||||
|
lines.append("Yes" if val else "No")
|
||||||
|
elif q["type"] in ("duration", "time"):
|
||||||
|
h, m = divmod(int(val), 60)
|
||||||
|
lines.append(f"{h:02d}:{m:02d}")
|
||||||
|
elif q["type"] == "multichoice":
|
||||||
|
lines.append(", ".join(val) if isinstance(val, list) else str(val))
|
||||||
|
else:
|
||||||
|
lines.append(str(val))
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
md = "\n".join(lines)
|
||||||
|
return Response(md, mimetype="text/markdown",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename=checkin-{date_str}.md"})
|
||||||
|
|
||||||
|
@app.route("/api/snapshot/now", methods=["POST"])
|
||||||
|
def snapshot_now():
|
||||||
|
take_snapshot()
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=5000, debug=False)
|
||||||
9
docker-compose.yml
Normal file
9
docker-compose.yml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
env_file: .env
|
||||||
|
volumes:
|
||||||
|
- ./questions.yaml:/app/questions.yaml:ro
|
||||||
50
questions.yaml
Normal file
50
questions.yaml
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
questions:
|
||||||
|
- id: mood
|
||||||
|
label: Overall mood today
|
||||||
|
type: scale
|
||||||
|
min: 1
|
||||||
|
max: 10
|
||||||
|
step: 1
|
||||||
|
|
||||||
|
- id: slept_well
|
||||||
|
label: Slept well last night?
|
||||||
|
type: yesno
|
||||||
|
|
||||||
|
- id: wake_time
|
||||||
|
label: Wake-up time
|
||||||
|
type: time
|
||||||
|
|
||||||
|
- id: focus_hours
|
||||||
|
label: Hours of deep focus
|
||||||
|
type: duration
|
||||||
|
|
||||||
|
- id: energy
|
||||||
|
label: Energy level
|
||||||
|
type: counter
|
||||||
|
min: 0
|
||||||
|
max: 10
|
||||||
|
step: 1
|
||||||
|
|
||||||
|
- id: tasks_done
|
||||||
|
label: Tasks completed
|
||||||
|
type: number
|
||||||
|
min: 0
|
||||||
|
max: 100
|
||||||
|
|
||||||
|
- id: primary_activity
|
||||||
|
label: Primary activity today
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- Work
|
||||||
|
- Study
|
||||||
|
- Rest
|
||||||
|
- Exercise
|
||||||
|
- Social
|
||||||
|
|
||||||
|
- id: wins
|
||||||
|
label: What went well?
|
||||||
|
type: textarea
|
||||||
|
|
||||||
|
- id: notes
|
||||||
|
label: Anything else?
|
||||||
|
type: text
|
||||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
flask==3.1.0
|
||||||
|
pymongo==4.10.1
|
||||||
|
pyyaml==6.0.2
|
||||||
|
apscheduler==3.10.4
|
||||||
|
python-dotenv==1.0.1
|
||||||
566
static/css/style.css
Normal file
566
static/css/style.css
Normal file
@@ -0,0 +1,566 @@
|
|||||||
|
/* macOS Mojave — SF Pro feel, blue accent, light/dark */
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--accent: #0a84ff;
|
||||||
|
--accent-hover: #409cff;
|
||||||
|
--accent-dim: rgba(10,132,255,0.15);
|
||||||
|
--accent-dim2: rgba(10,132,255,0.08);
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--radius: 10px;
|
||||||
|
--radius-lg: 14px;
|
||||||
|
--font: -apple-system, 'SF Pro Text', 'Inter', sans-serif;
|
||||||
|
--font-display: -apple-system, 'SF Pro Display', 'Inter', sans-serif;
|
||||||
|
--transition: 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark (default) */
|
||||||
|
:root, [data-theme="dark"] {
|
||||||
|
--bg: #1c1c1e;
|
||||||
|
--bg2: #2c2c2e;
|
||||||
|
--bg3: #3a3a3c;
|
||||||
|
--surface: #2c2c2e;
|
||||||
|
--surface2: #3a3a3c;
|
||||||
|
--border: rgba(255,255,255,0.1);
|
||||||
|
--border-active: rgba(10,132,255,0.6);
|
||||||
|
--text: #ffffff;
|
||||||
|
--text2: rgba(255,255,255,0.6);
|
||||||
|
--text3: rgba(255,255,255,0.3);
|
||||||
|
--shadow: 0 2px 16px rgba(0,0,0,0.4);
|
||||||
|
--shadow-sm: 0 1px 4px rgba(0,0,0,0.3);
|
||||||
|
--success-bg: rgba(48,209,88,0.15);
|
||||||
|
--success: #30d158;
|
||||||
|
--danger: #ff453a;
|
||||||
|
--danger-bg: rgba(255,69,58,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg: #f2f2f7;
|
||||||
|
--bg2: #ffffff;
|
||||||
|
--bg3: #e5e5ea;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface2: #f2f2f7;
|
||||||
|
--border: rgba(0,0,0,0.1);
|
||||||
|
--border-active: rgba(10,132,255,0.5);
|
||||||
|
--text: #000000;
|
||||||
|
--text2: rgba(0,0,0,0.55);
|
||||||
|
--text3: rgba(0,0,0,0.25);
|
||||||
|
--shadow: 0 2px 16px rgba(0,0,0,0.1);
|
||||||
|
--shadow-sm: 0 1px 4px rgba(0,0,0,0.08);
|
||||||
|
--success-bg: rgba(48,209,88,0.12);
|
||||||
|
--success: #28a745;
|
||||||
|
--danger: #ff3b30;
|
||||||
|
--danger-bg: rgba(255,59,48,0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
html { font-size: 15px; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-weight: 400;
|
||||||
|
min-height: 100vh;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
transition: background var(--transition), color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── TOPBAR ── */
|
||||||
|
.topbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
background: rgba(28,28,30,0.8);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 0 24px;
|
||||||
|
height: 52px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
[data-theme="light"] .topbar {
|
||||||
|
background: rgba(242,242,247,0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-nav a {
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text2);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
.topbar-nav a:hover { background: var(--surface2); color: var(--text); }
|
||||||
|
.topbar-nav a.active { background: var(--accent-dim); color: var(--accent); }
|
||||||
|
|
||||||
|
/* Theme toggle */
|
||||||
|
.theme-toggle {
|
||||||
|
width: 32px; height: 32px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface2);
|
||||||
|
color: var(--text2);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 15px;
|
||||||
|
transition: all var(--transition);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.theme-toggle:hover { color: var(--text); background: var(--bg3); }
|
||||||
|
|
||||||
|
/* ── MAIN LAYOUT ── */
|
||||||
|
.page { max-width: 640px; margin: 0 auto; padding: 32px 24px 96px; }
|
||||||
|
.page-wide { max-width: 900px; margin: 0 auto; padding: 32px 24px 96px; }
|
||||||
|
|
||||||
|
/* ── PERIOD BANNER ── */
|
||||||
|
.period-banner {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 14px 18px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
.period-icon { font-size: 18px; flex-shrink: 0; }
|
||||||
|
.period-text { font-size: 13px; color: var(--text2); line-height: 1.5; }
|
||||||
|
.period-text strong { color: var(--text); font-weight: 500; }
|
||||||
|
.period-progress {
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.period-pct {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.period-bar-wrap {
|
||||||
|
width: 80px;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--bg3);
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-top: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.period-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── STATUS DOT ── */
|
||||||
|
#status {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text3);
|
||||||
|
transition: color var(--transition);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
#status::before {
|
||||||
|
content: '';
|
||||||
|
width: 6px; height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text3);
|
||||||
|
display: inline-block;
|
||||||
|
transition: background var(--transition);
|
||||||
|
}
|
||||||
|
#status.saving { color: var(--accent); }
|
||||||
|
#status.saving::before { background: var(--accent); }
|
||||||
|
#status.saved { color: var(--success); }
|
||||||
|
#status.saved::before { background: var(--success); }
|
||||||
|
#status.error { color: var(--danger); }
|
||||||
|
#status.error::before { background: var(--danger); }
|
||||||
|
|
||||||
|
/* ── CARD ── */
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: border-color var(--transition);
|
||||||
|
}
|
||||||
|
.card:focus-within { border-color: var(--border-active); }
|
||||||
|
|
||||||
|
/* ── QUESTION ── */
|
||||||
|
.question { margin-bottom: 16px; }
|
||||||
|
.question-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text2);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── YESNO ── */
|
||||||
|
.yesno { display: flex; gap: 8px; }
|
||||||
|
.yesno button {
|
||||||
|
flex: 1; padding: 10px 16px;
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text2);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
.yesno button:hover { background: var(--bg3); color: var(--text); }
|
||||||
|
.yesno button.active-yes { background: var(--accent-dim); border-color: var(--accent); color: var(--accent); }
|
||||||
|
.yesno button.active-no { background: var(--danger-bg); border-color: var(--danger); color: var(--danger); }
|
||||||
|
|
||||||
|
/* ── SCALE ── */
|
||||||
|
.scale-wrap { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.scale-top { display: flex; align-items: baseline; justify-content: space-between; }
|
||||||
|
.scale-value { font-size: 32px; font-weight: 600; color: var(--accent); letter-spacing: -0.03em; font-family: var(--font-display); }
|
||||||
|
.scale-range { font-size: 12px; color: var(--text3); }
|
||||||
|
input[type=range] {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 100%; height: 4px;
|
||||||
|
background: var(--bg3);
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
input[type=range]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 20px; height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 0 0 4px var(--accent-dim), var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── NUMBER ── */
|
||||||
|
input[type=number] {
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 10px 14px;
|
||||||
|
width: 140px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color var(--transition);
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
input[type=number]::-webkit-outer-spin-button,
|
||||||
|
input[type=number]::-webkit-inner-spin-button { -webkit-appearance: none; }
|
||||||
|
input[type=number]:focus { border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* ── DURATION / TIME ── */
|
||||||
|
.duration-wrap, .time-wrap {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
}
|
||||||
|
.duration-wrap input[type=number],
|
||||||
|
.time-wrap input[type=number] {
|
||||||
|
width: 76px; text-align: center;
|
||||||
|
}
|
||||||
|
.time-sep {
|
||||||
|
font-size: 22px; font-weight: 600;
|
||||||
|
color: var(--text3); line-height: 1;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── COUNTER ── */
|
||||||
|
.counter-wrap { display: flex; align-items: center; gap: 0; }
|
||||||
|
.counter-btn {
|
||||||
|
width: 52px; height: 52px;
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 22px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
font-family: var(--font);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-weight: 300;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.counter-btn:first-child { border-radius: var(--radius) 0 0 var(--radius); }
|
||||||
|
.counter-btn:last-child { border-radius: 0 var(--radius) var(--radius) 0; }
|
||||||
|
.counter-btn:hover { background: var(--bg3); }
|
||||||
|
.counter-btn:active { background: var(--accent-dim); color: var(--accent); }
|
||||||
|
.counter-val {
|
||||||
|
min-width: 68px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 9px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── TEXT / TEXTAREA ── */
|
||||||
|
input[type=text], textarea {
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
width: 100%;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color var(--transition);
|
||||||
|
}
|
||||||
|
input[type=text]:focus, textarea:focus { border-color: var(--accent); }
|
||||||
|
textarea { min-height: 88px; resize: vertical; line-height: 1.5; }
|
||||||
|
::placeholder { color: var(--text3); }
|
||||||
|
|
||||||
|
/* ── CHOICE / MULTICHOICE ── */
|
||||||
|
.choice-wrap, .multichoice-wrap { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.choice-btn {
|
||||||
|
padding: 7px 15px;
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 100px;
|
||||||
|
color: var(--text2);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
.choice-btn:hover { background: var(--bg3); color: var(--text); }
|
||||||
|
.choice-btn.active { background: var(--accent-dim); border-color: var(--accent); color: var(--accent); }
|
||||||
|
|
||||||
|
/* ── DONE CHECKBOX ── */
|
||||||
|
.done-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 18px 20px;
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
.done-card:hover { border-color: var(--border-active); }
|
||||||
|
.done-card.checked {
|
||||||
|
background: var(--success-bg);
|
||||||
|
border-color: var(--success);
|
||||||
|
}
|
||||||
|
.done-checkbox {
|
||||||
|
width: 22px; height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--border-active);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all var(--transition);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.done-card.checked .done-checkbox {
|
||||||
|
background: var(--success);
|
||||||
|
border-color: var(--success);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.done-label { font-size: 14px; font-weight: 500; color: var(--text); }
|
||||||
|
.done-sub { font-size: 12px; color: var(--text2); margin-top: 2px; }
|
||||||
|
|
||||||
|
/* ── CALENDAR PAGE ── */
|
||||||
|
.cal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.cal-header h2 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.cal-nav-btn {
|
||||||
|
width: 32px; height: 32px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text2);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
.cal-nav-btn:hover { background: var(--bg3); color: var(--text); }
|
||||||
|
|
||||||
|
.cal-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.cal-dow {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text3);
|
||||||
|
padding: 4px 0 8px;
|
||||||
|
}
|
||||||
|
.cal-cell {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text2);
|
||||||
|
cursor: default;
|
||||||
|
transition: all var(--transition);
|
||||||
|
position: relative;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
.cal-cell.empty { background: transparent; border-color: transparent; }
|
||||||
|
.cal-cell.has-snapshot {
|
||||||
|
background: var(--accent-dim2);
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.cal-cell.has-snapshot:hover { border-color: var(--accent); background: var(--accent-dim); }
|
||||||
|
.cal-cell.done { background: var(--success-bg); border-color: var(--success); }
|
||||||
|
.cal-cell.done .cal-day-num { color: var(--success); }
|
||||||
|
.cal-cell.today { border-color: var(--accent); }
|
||||||
|
.cal-cell.today .cal-day-num { color: var(--accent); font-weight: 700; }
|
||||||
|
.cal-day-num { font-size: 15px; line-height: 1; }
|
||||||
|
.cal-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--accent); }
|
||||||
|
.cal-done-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--success); }
|
||||||
|
|
||||||
|
/* ── SNAPSHOT DRAWER ── */
|
||||||
|
.snapshot-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; right: -480px;
|
||||||
|
width: 460px;
|
||||||
|
height: 100vh;
|
||||||
|
background: var(--surface);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
box-shadow: -8px 0 32px rgba(0,0,0,0.3);
|
||||||
|
z-index: 200;
|
||||||
|
transition: right 0.3s cubic-bezier(0.4,0,0.2,1);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.snapshot-panel.open { right: 0; }
|
||||||
|
.snapshot-panel-header {
|
||||||
|
padding: 20px 20px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.snapshot-panel-title { font-size: 16px; font-weight: 600; flex: 1; }
|
||||||
|
.snapshot-panel-body { flex: 1; overflow-y: auto; padding: 20px; }
|
||||||
|
.panel-close-btn {
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface2);
|
||||||
|
color: var(--text2);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
.panel-close-btn:hover { background: var(--bg3); color: var(--text); }
|
||||||
|
|
||||||
|
.snapshot-qa { margin-bottom: 18px; }
|
||||||
|
.snapshot-q { font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text3); margin-bottom: 4px; }
|
||||||
|
.snapshot-a { font-size: 14px; color: var(--text); }
|
||||||
|
|
||||||
|
.download-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: var(--accent-dim);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all var(--transition);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.download-btn:hover { background: var(--accent); color: white; }
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.4);
|
||||||
|
z-index: 199;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
.overlay.visible { display: block; }
|
||||||
|
|
||||||
|
/* ── UTILS ── */
|
||||||
|
.section-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text3);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
|
.section-title:first-child { margin-top: 0; }
|
||||||
|
|
||||||
|
/* Early submit button */
|
||||||
|
.early-submit-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: var(--danger-bg);
|
||||||
|
border: 1px solid var(--danger);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font);
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
.early-submit-btn:hover { background: var(--danger); color: white; }
|
||||||
22
static/js/theme.js
Normal file
22
static/js/theme.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// Theme toggle — persisted to localStorage
|
||||||
|
(function() {
|
||||||
|
const saved = localStorage.getItem('theme') || 'dark';
|
||||||
|
document.documentElement.setAttribute('data-theme', saved);
|
||||||
|
})();
|
||||||
|
|
||||||
|
function initThemeToggle() {
|
||||||
|
const btn = document.getElementById('theme-toggle');
|
||||||
|
if (!btn) return;
|
||||||
|
function update() {
|
||||||
|
const t = document.documentElement.getAttribute('data-theme');
|
||||||
|
btn.textContent = t === 'dark' ? '☀️' : '🌙';
|
||||||
|
}
|
||||||
|
update();
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const current = document.documentElement.getAttribute('data-theme');
|
||||||
|
const next = current === 'dark' ? 'light' : 'dark';
|
||||||
|
document.documentElement.setAttribute('data-theme', next);
|
||||||
|
localStorage.setItem('theme', next);
|
||||||
|
update();
|
||||||
|
});
|
||||||
|
}
|
||||||
198
templates/history.html
Normal file
198
templates/history.html
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>History — Daily Check-in</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
<script src="/static/js/theme.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<nav class="topbar">
|
||||||
|
<span class="topbar-title">Daily Check-in</span>
|
||||||
|
<div class="topbar-nav">
|
||||||
|
<a href="/">Form</a>
|
||||||
|
<a href="/history" class="active">History</a>
|
||||||
|
</div>
|
||||||
|
<button class="theme-toggle" id="theme-toggle" title="Toggle theme"></button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="page-wide">
|
||||||
|
|
||||||
|
<div class="cal-header">
|
||||||
|
<button class="cal-nav-btn" id="prev-btn">‹</button>
|
||||||
|
<h2 id="month-label"></h2>
|
||||||
|
<button class="cal-nav-btn" id="next-btn">›</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cal-grid" id="cal-grid"></div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Overlay -->
|
||||||
|
<div class="overlay" id="overlay" onclick="closePanel()"></div>
|
||||||
|
|
||||||
|
<!-- Snapshot panel -->
|
||||||
|
<div class="snapshot-panel" id="snapshot-panel">
|
||||||
|
<div class="snapshot-panel-header">
|
||||||
|
<span class="snapshot-panel-title" id="panel-title">—</span>
|
||||||
|
<button class="panel-close-btn" onclick="closePanel()">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="snapshot-panel-body" id="panel-body">
|
||||||
|
<p style="color:var(--text3);font-size:13px">Select a day to view answers.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const QUESTIONS = {{ questions | tojson }};
|
||||||
|
let snapshots = {}; // date string → snapshot doc
|
||||||
|
let curYear, curMonth;
|
||||||
|
|
||||||
|
const DAYS = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'];
|
||||||
|
const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||||||
|
|
||||||
|
async function loadSnapshots() {
|
||||||
|
const res = await fetch('/api/snapshots?limit=365');
|
||||||
|
const list = await res.json();
|
||||||
|
snapshots = {};
|
||||||
|
list.forEach(s => { snapshots[s.date] = s; });
|
||||||
|
renderCalendar();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCalendar() {
|
||||||
|
const label = document.getElementById('month-label');
|
||||||
|
label.textContent = MONTHS[curMonth] + ' ' + curYear;
|
||||||
|
|
||||||
|
const grid = document.getElementById('cal-grid');
|
||||||
|
grid.innerHTML = '';
|
||||||
|
|
||||||
|
// Day of week headers
|
||||||
|
DAYS.forEach(d => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'cal-dow';
|
||||||
|
el.textContent = d;
|
||||||
|
grid.appendChild(el);
|
||||||
|
});
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const todayStr = fmtDate(today);
|
||||||
|
|
||||||
|
// First day of month — Monday-based offset
|
||||||
|
const firstDay = new Date(curYear, curMonth, 1);
|
||||||
|
let offset = firstDay.getDay(); // 0=Sun
|
||||||
|
offset = offset === 0 ? 6 : offset - 1; // convert to Mon=0
|
||||||
|
|
||||||
|
for (let i = 0; i < offset; i++) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'cal-cell empty';
|
||||||
|
grid.appendChild(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
const daysInMonth = new Date(curYear, curMonth + 1, 0).getDate();
|
||||||
|
for (let d = 1; d <= daysInMonth; d++) {
|
||||||
|
const dateStr = `${curYear}-${String(curMonth+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
|
||||||
|
const snap = snapshots[dateStr];
|
||||||
|
const isToday = dateStr === todayStr;
|
||||||
|
const isDone = snap && snap.answers && snap.answers.__done__;
|
||||||
|
const hasSnap = !!snap;
|
||||||
|
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'cal-cell' +
|
||||||
|
(hasSnap ? ' has-snapshot' : '') +
|
||||||
|
(isDone ? ' done' : '') +
|
||||||
|
(isToday ? ' today' : '');
|
||||||
|
|
||||||
|
el.innerHTML = `<span class="cal-day-num">${d}</span>`;
|
||||||
|
if (hasSnap && !isDone) el.innerHTML += `<span class="cal-dot"></span>`;
|
||||||
|
if (isDone) el.innerHTML += `<span class="cal-done-dot"></span>`;
|
||||||
|
|
||||||
|
if (hasSnap) el.addEventListener('click', () => openPanel(dateStr, snap));
|
||||||
|
grid.appendChild(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(d) {
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPanel(dateStr, snap) {
|
||||||
|
document.getElementById('panel-title').textContent = formatDateNice(dateStr);
|
||||||
|
const body = document.getElementById('panel-body');
|
||||||
|
|
||||||
|
// Download button
|
||||||
|
let html = `<a class="download-btn" href="/api/snapshots/${dateStr}/md" download="checkin-${dateStr}.md">⬇ Download .md</a>`;
|
||||||
|
|
||||||
|
// Done badge
|
||||||
|
if (snap.answers && snap.answers.__done__) {
|
||||||
|
const at = snap.answers.__done_at__ ? new Date(snap.answers.__done_at__).toLocaleTimeString('en-GB', {hour:'2-digit',minute:'2-digit'}) : '';
|
||||||
|
html += `<div style="display:inline-flex;align-items:center;gap:6px;padding:6px 12px;background:var(--success-bg);border:1px solid var(--success);border-radius:100px;font-size:12px;color:var(--success);margin-bottom:20px;margin-left:8px;">✓ Filled ${at ? 'at ' + at : ''}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `<div style="border-top:1px solid var(--border);padding-top:20px;">`;
|
||||||
|
QUESTIONS.forEach(q => {
|
||||||
|
const val = snap.answers ? snap.answers[q.id] : null;
|
||||||
|
html += `<div class="snapshot-qa">
|
||||||
|
<div class="snapshot-q">${escHtml(q.label)}</div>
|
||||||
|
<div class="snapshot-a">${formatVal(q, val)}</div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
html += `</div>`;
|
||||||
|
|
||||||
|
body.innerHTML = html;
|
||||||
|
document.getElementById('snapshot-panel').classList.add('open');
|
||||||
|
document.getElementById('overlay').classList.add('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePanel() {
|
||||||
|
document.getElementById('snapshot-panel').classList.remove('open');
|
||||||
|
document.getElementById('overlay').classList.remove('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatVal(q, val) {
|
||||||
|
if (val == null) return `<span style="color:var(--text3)">—</span>`;
|
||||||
|
switch(q.type) {
|
||||||
|
case 'yesno': return val ? '✅ Yes' : '❌ No';
|
||||||
|
case 'duration': {
|
||||||
|
const h = Math.floor(val/60), m = val%60;
|
||||||
|
return `${h}h ${String(m).padStart(2,'0')}m`;
|
||||||
|
}
|
||||||
|
case 'time': {
|
||||||
|
const h = Math.floor(val/60), m = val%60;
|
||||||
|
return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}`;
|
||||||
|
}
|
||||||
|
case 'multichoice': return Array.isArray(val) ? val.join(', ') : String(val);
|
||||||
|
default: return escHtml(String(val));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateNice(dateStr) {
|
||||||
|
const d = new Date(dateStr + 'T12:00:00');
|
||||||
|
return d.toLocaleDateString('en-GB', {weekday:'long', day:'numeric', month:'long', year:'numeric'});
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nav
|
||||||
|
const now = new Date();
|
||||||
|
curYear = now.getFullYear();
|
||||||
|
curMonth = now.getMonth();
|
||||||
|
|
||||||
|
document.getElementById('prev-btn').addEventListener('click', () => {
|
||||||
|
curMonth--;
|
||||||
|
if (curMonth < 0) { curMonth = 11; curYear--; }
|
||||||
|
renderCalendar();
|
||||||
|
});
|
||||||
|
document.getElementById('next-btn').addEventListener('click', () => {
|
||||||
|
curMonth++;
|
||||||
|
if (curMonth > 11) { curMonth = 0; curYear++; }
|
||||||
|
renderCalendar();
|
||||||
|
});
|
||||||
|
|
||||||
|
loadSnapshots();
|
||||||
|
initThemeToggle();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
390
templates/index.html
Normal file
390
templates/index.html
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Daily Check-in</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
<script src="/static/js/theme.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<nav class="topbar">
|
||||||
|
<span class="topbar-title">Daily Check-in</span>
|
||||||
|
<div class="topbar-nav">
|
||||||
|
<a href="/" class="active">Form</a>
|
||||||
|
<a href="/history">History</a>
|
||||||
|
</div>
|
||||||
|
<div id="status" title="Save status"></div>
|
||||||
|
<button class="theme-toggle" id="theme-toggle" title="Toggle theme"></button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
|
||||||
|
<!-- Period banner -->
|
||||||
|
<div class="period-banner" id="period-banner">
|
||||||
|
<span class="period-icon">🕐</span>
|
||||||
|
<div class="period-text" id="period-text">Loading period…</div>
|
||||||
|
<div class="period-progress">
|
||||||
|
<div class="period-pct" id="period-pct">—</div>
|
||||||
|
<div class="period-bar-wrap"><div class="period-bar" id="period-bar" style="width:0%"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Questions -->
|
||||||
|
<div id="form"></div>
|
||||||
|
|
||||||
|
<!-- Actions row -->
|
||||||
|
<div style="display:flex;gap:10px;align-items:center;margin-bottom:12px;flex-wrap:wrap;">
|
||||||
|
<a class="download-btn" href="#" onclick="downloadCurrentMd(event)">⬇ Download as .md</a>
|
||||||
|
<button class="early-submit-btn" onclick="earlySubmit()">⏭ Start new day</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Confirmation dialog -->
|
||||||
|
<div id="confirm-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:300;backdrop-filter:blur(4px);">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:center;height:100%;">
|
||||||
|
<div style="background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);padding:28px 24px;max-width:340px;width:90%;box-shadow:var(--shadow);">
|
||||||
|
<div style="font-size:17px;font-weight:600;margin-bottom:8px;">Start new day?</div>
|
||||||
|
<div style="font-size:13px;color:var(--text2);margin-bottom:24px;line-height:1.5;">This will snapshot current answers and reset the form — same as the automatic 10:00 submission, just now.</div>
|
||||||
|
<div style="display:flex;gap:10px;">
|
||||||
|
<button onclick="confirmEarlySubmit()" style="flex:1;padding:10px;background:var(--accent);border:none;border-radius:var(--radius);color:white;font-size:14px;font-weight:600;cursor:pointer;">Submit now</button>
|
||||||
|
<button onclick="closeConfirm()" style="flex:1;padding:10px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);font-size:14px;cursor:pointer;">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Done checkbox -->
|
||||||
|
<div class="done-card" id="done-card" onclick="toggleDone()">
|
||||||
|
<div class="done-checkbox" id="done-checkbox"></div>
|
||||||
|
<div>
|
||||||
|
<div class="done-label">Mark as fully filled</div>
|
||||||
|
<div class="done-sub" id="done-sub">Not marked yet</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const QUESTIONS = {{ questions | tojson }};
|
||||||
|
const PERIOD = {{ period | tojson }};
|
||||||
|
|
||||||
|
let answers = {};
|
||||||
|
let saveTimer = null;
|
||||||
|
|
||||||
|
// ── Status ──
|
||||||
|
function setStatus(s) {
|
||||||
|
const el = document.getElementById('status');
|
||||||
|
el.className = s;
|
||||||
|
if (s === 'saving') el.textContent = 'saving';
|
||||||
|
else if (s === 'saved') el.textContent = 'saved';
|
||||||
|
else if (s === 'error') el.textContent = 'error';
|
||||||
|
else el.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Period banner ──
|
||||||
|
function renderPeriod() {
|
||||||
|
if (!PERIOD.start || !PERIOD.end) {
|
||||||
|
document.getElementById('period-text').innerHTML = '<strong>No active period configured</strong>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const start = new Date(PERIOD.start);
|
||||||
|
const end = new Date(PERIOD.end);
|
||||||
|
const now = new Date();
|
||||||
|
const fmt = d => d.toLocaleString('en-GB', {day:'numeric', month:'short', hour:'2-digit', minute:'2-digit', hour12:false});
|
||||||
|
document.getElementById('period-text').innerHTML =
|
||||||
|
`<strong>${fmt(start)}</strong> → <strong>${fmt(end)}</strong>`;
|
||||||
|
const total = end - start;
|
||||||
|
const elapsed = Math.max(0, Math.min(now - start, total));
|
||||||
|
const pct = total > 0 ? Math.round(elapsed / total * 100) : 0;
|
||||||
|
document.getElementById('period-pct').textContent = pct + '%';
|
||||||
|
document.getElementById('period-bar').style.width = pct + '%';
|
||||||
|
}
|
||||||
|
renderPeriod();
|
||||||
|
|
||||||
|
// ── Load & save ──
|
||||||
|
async function loadAnswers() {
|
||||||
|
const res = await fetch('/api/answers');
|
||||||
|
answers = await res.json();
|
||||||
|
renderAll();
|
||||||
|
renderDone();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSave() {
|
||||||
|
clearTimeout(saveTimer);
|
||||||
|
setStatus('saving');
|
||||||
|
saveTimer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/answers', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify(answers)
|
||||||
|
});
|
||||||
|
setStatus('saved');
|
||||||
|
setTimeout(() => setStatus('idle'), 2000);
|
||||||
|
} catch(e) { setStatus('error'); }
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
function set(id, val) { answers[id] = val; scheduleSave(); }
|
||||||
|
|
||||||
|
// ── Done checkbox ──
|
||||||
|
async function toggleDone() {
|
||||||
|
const current = answers.__done__ || false;
|
||||||
|
const next = !current;
|
||||||
|
answers.__done__ = next;
|
||||||
|
answers.__done_at__ = next ? new Date().toISOString() : null;
|
||||||
|
scheduleSave();
|
||||||
|
renderDone();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDone() {
|
||||||
|
const card = document.getElementById('done-card');
|
||||||
|
const box = document.getElementById('done-checkbox');
|
||||||
|
const sub = document.getElementById('done-sub');
|
||||||
|
const done = answers.__done__;
|
||||||
|
card.classList.toggle('checked', !!done);
|
||||||
|
box.textContent = done ? '✓' : '';
|
||||||
|
if (done && answers.__done_at__) {
|
||||||
|
const d = new Date(answers.__done_at__);
|
||||||
|
sub.textContent = 'Marked at ' + d.toLocaleTimeString('en-GB', {hour:'2-digit', minute:'2-digit'});
|
||||||
|
} else {
|
||||||
|
sub.textContent = 'Not marked yet';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render questions ──
|
||||||
|
function renderAll() {
|
||||||
|
const form = document.getElementById('form');
|
||||||
|
form.innerHTML = '';
|
||||||
|
QUESTIONS.forEach(q => {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'question';
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'card';
|
||||||
|
card.innerHTML = `<div class="question-label">${escHtml(q.label)}</div>` + renderField(q);
|
||||||
|
wrap.appendChild(card);
|
||||||
|
form.appendChild(wrap);
|
||||||
|
attachListeners(q, card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderField(q) {
|
||||||
|
const val = answers[q.id];
|
||||||
|
switch(q.type) {
|
||||||
|
|
||||||
|
case 'yesno': {
|
||||||
|
const y = val === true, n = val === false;
|
||||||
|
return `<div class="yesno">
|
||||||
|
<button data-id="${q.id}" data-val="yes" class="${y?'active-yes':''}">Yes</button>
|
||||||
|
<button data-id="${q.id}" data-val="no" class="${n?'active-no':''}">No</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'scale': {
|
||||||
|
const v = val ?? Math.round(((q.min??1)+(q.max??10))/2);
|
||||||
|
return `<div class="scale-wrap">
|
||||||
|
<div class="scale-top">
|
||||||
|
<span class="scale-value" id="sv-${q.id}">${v}</span>
|
||||||
|
<span class="scale-range">${q.min??1} – ${q.max??10}</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" data-id="${q.id}" min="${q.min??1}" max="${q.max??10}" step="${q.step??1}" value="${v}">
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'number': {
|
||||||
|
return `<input type="number" data-id="${q.id}" min="${q.min??''}" max="${q.max??''}" value="${val??''}" placeholder="—">`;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'duration': {
|
||||||
|
const h = val != null ? Math.floor(val/60) : '';
|
||||||
|
const m = val != null ? String(val%60).padStart(2,'0') : '';
|
||||||
|
return `<div class="duration-wrap">
|
||||||
|
<input type="number" data-id="${q.id}" data-part="h" min="0" max="23" value="${h}" placeholder="h">
|
||||||
|
<span class="time-sep">:</span>
|
||||||
|
<input type="number" data-id="${q.id}" data-part="m" min="0" max="59" value="${m}" placeholder="mm">
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'time': {
|
||||||
|
const h = val != null ? String(Math.floor(val/60)).padStart(2,'0') : '';
|
||||||
|
const m = val != null ? String(val%60).padStart(2,'0') : '';
|
||||||
|
return `<div class="time-wrap">
|
||||||
|
<input type="number" data-id="${q.id}" data-part="h" min="0" max="23" value="${h}" placeholder="HH">
|
||||||
|
<span class="time-sep">:</span>
|
||||||
|
<input type="number" data-id="${q.id}" data-part="m" min="0" max="59" value="${m}" placeholder="MM">
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'counter': {
|
||||||
|
const v = val ?? 0;
|
||||||
|
return `<div class="counter-wrap">
|
||||||
|
<button class="counter-btn" data-id="${q.id}" data-action="dec">−</button>
|
||||||
|
<div class="counter-val" id="cv-${q.id}">${v}</div>
|
||||||
|
<button class="counter-btn" data-id="${q.id}" data-action="inc">+</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'text':
|
||||||
|
return `<input type="text" data-id="${q.id}" value="${escHtml(val??'')}" placeholder="…">`;
|
||||||
|
|
||||||
|
case 'textarea':
|
||||||
|
return `<textarea data-id="${q.id}" placeholder="…">${escHtml(val??'')}</textarea>`;
|
||||||
|
|
||||||
|
case 'choice': {
|
||||||
|
return `<div class="choice-wrap">${q.options.map(o =>
|
||||||
|
`<button class="choice-btn${val===o?' active':''}" data-id="${q.id}" data-val="${escHtml(o)}">${escHtml(o)}</button>`
|
||||||
|
).join('')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'multichoice': {
|
||||||
|
const sel = Array.isArray(val) ? val : [];
|
||||||
|
return `<div class="multichoice-wrap">${q.options.map(o =>
|
||||||
|
`<button class="choice-btn${sel.includes(o)?' active':''}" data-id="${q.id}" data-val="${escHtml(o)}">${escHtml(o)}</button>`
|
||||||
|
).join('')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
default: return `<span style="color:var(--text3);font-size:12px">unknown type: ${q.type}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachListeners(q, div) {
|
||||||
|
switch(q.type) {
|
||||||
|
|
||||||
|
case 'yesno':
|
||||||
|
div.querySelectorAll('button[data-id]').forEach(btn => btn.addEventListener('click', () => {
|
||||||
|
const v = btn.dataset.val === 'yes';
|
||||||
|
set(q.id, v);
|
||||||
|
div.querySelectorAll('button[data-id]').forEach(b => b.className = '');
|
||||||
|
btn.className = v ? 'active-yes' : 'active-no';
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'scale': {
|
||||||
|
const inp = div.querySelector('input[type=range]');
|
||||||
|
const display = div.querySelector(`#sv-${q.id}`);
|
||||||
|
inp.addEventListener('input', () => { display.textContent = inp.value; set(q.id, Number(inp.value)); });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'number':
|
||||||
|
div.querySelector('input[type=number]').addEventListener('input', e => {
|
||||||
|
set(q.id, e.target.value === '' ? null : Number(e.target.value));
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'duration':
|
||||||
|
case 'time':
|
||||||
|
div.querySelectorAll('input[type=number]').forEach(inp => {
|
||||||
|
inp.addEventListener('input', () => {
|
||||||
|
const h = parseInt(div.querySelector('[data-part=h]').value) || 0;
|
||||||
|
const m = parseInt(div.querySelector('[data-part=m]').value) || 0;
|
||||||
|
set(q.id, h * 60 + m);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'counter': {
|
||||||
|
const display = div.querySelector(`#cv-${q.id}`);
|
||||||
|
const cfg = QUESTIONS.find(x => x.id === q.id);
|
||||||
|
div.querySelectorAll('.counter-btn').forEach(btn => btn.addEventListener('click', () => {
|
||||||
|
let v = answers[q.id] ?? 0;
|
||||||
|
if (btn.dataset.action === 'inc') v = Math.min(v + (cfg.step||1), cfg.max ?? Infinity);
|
||||||
|
else v = Math.max(v - (cfg.step||1), cfg.min ?? -Infinity);
|
||||||
|
display.textContent = v;
|
||||||
|
set(q.id, v);
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'text':
|
||||||
|
div.querySelector('input[type=text]').addEventListener('input', e => set(q.id, e.target.value));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'textarea':
|
||||||
|
div.querySelector('textarea').addEventListener('input', e => set(q.id, e.target.value));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'choice':
|
||||||
|
div.querySelectorAll('.choice-btn').forEach(btn => btn.addEventListener('click', () => {
|
||||||
|
set(q.id, btn.dataset.val);
|
||||||
|
div.querySelectorAll('.choice-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'multichoice':
|
||||||
|
div.querySelectorAll('.choice-btn').forEach(btn => btn.addEventListener('click', () => {
|
||||||
|
let sel = Array.isArray(answers[q.id]) ? [...answers[q.id]] : [];
|
||||||
|
const v = btn.dataset.val;
|
||||||
|
sel = sel.includes(v) ? sel.filter(x => x !== v) : [...sel, v];
|
||||||
|
set(q.id, sel);
|
||||||
|
btn.classList.toggle('active');
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValMd(q, val) {
|
||||||
|
if (val == null) return '_(no answer)_';
|
||||||
|
switch(q.type) {
|
||||||
|
case 'yesno': return val ? 'Yes' : 'No';
|
||||||
|
case 'duration':
|
||||||
|
case 'time': { const h = Math.floor(val/60), m = val%60; return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}`; }
|
||||||
|
case 'multichoice': return Array.isArray(val) ? val.join(', ') : String(val);
|
||||||
|
default: return String(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadCurrentMd(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const periodStart = PERIOD.start ? new Date(PERIOD.start) : new Date();
|
||||||
|
const dateStr = periodStart.toISOString().slice(0, 10);
|
||||||
|
const now = new Date();
|
||||||
|
const lines = [
|
||||||
|
`# Daily Check-in — ${dateStr}`,
|
||||||
|
'',
|
||||||
|
`_Downloaded at: ${now.toLocaleString('en-GB')}_`,
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
QUESTIONS.forEach(q => {
|
||||||
|
lines.push(`## ${q.label}`, '', formatValMd(q, answers[q.id]), '');
|
||||||
|
});
|
||||||
|
const blob = new Blob([lines.join('\n')], {type: 'text/markdown'});
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = `checkin-${dateStr}-draft.md`;
|
||||||
|
a.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function earlySubmit() {
|
||||||
|
document.getElementById('confirm-overlay').style.display = 'block';
|
||||||
|
}
|
||||||
|
function closeConfirm() {
|
||||||
|
document.getElementById('confirm-overlay').style.display = 'none';
|
||||||
|
}
|
||||||
|
async function confirmEarlySubmit() {
|
||||||
|
closeConfirm();
|
||||||
|
setStatus('saving');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/snapshot/now', {method: 'POST'});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
answers = {};
|
||||||
|
renderAll();
|
||||||
|
renderDone();
|
||||||
|
setStatus('saved');
|
||||||
|
setTimeout(() => { setStatus('idle'); window.location.reload(); }, 1000);
|
||||||
|
} catch(e) { setStatus('error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAnswers();
|
||||||
|
initThemeToggle();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user