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