First commit

This commit is contained in:
2026-04-12 18:55:00 +03:00
commit 8f1bf14191
12 changed files with 1451 additions and 0 deletions

2
.env Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// 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
View 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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>