Skip to content

Commit c0a3678

Browse files
committed
Merge branch 'main' into feat/add-task-to-project
Resolve conflict in static/app.js: keep linkify on task names and tag pill (tagNameForTask / .t-tag). Made-with: Cursor
2 parents 0e9c7b5 + e5dc490 commit c0a3678

14 files changed

Lines changed: 1440 additions & 85 deletions

.github/workflows/fly-deploy.yml

Lines changed: 0 additions & 18 deletions
This file was deleted.

Makefile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
.PHONY: dev
2+
3+
dev:
4+
@lsof -ti:8000 | xargs kill -9 2>/dev/null || true
5+
uvicorn app:app --reload

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,39 @@ Later items can be marked as done. Each later item shows a green ✓ button (to
221221
| `GET` | `/done?offset=0&limit=50` | Paginated list of done items |
222222
| `GET` | `/done/stats` | Stats: `this_week`, `this_month`, `avg_per_week` |
223223

224+
## Shared profile
225+
226+
Logged-in users can share a read-only view of their tasks, history, done list, and monthly report with anyone via a unique link.
227+
228+
**How it works**
229+
230+
1. Click "share live view" in the top bar (next to the theme toggle). A popover opens with an Enable/Disable toggle.
231+
2. Click "Enable" to generate a share link. The link appears in the popover with a "Copy" button.
232+
3. The link looks like `https://doingit.online/shared/<uuid>`. Anyone who opens it sees the user's live tasks, history, done list, and monthly report — all read-only, no login required. The shared view polls every 5 seconds for live updates.
233+
4. Click "Disable" to revoke the share token. Existing links stop working.
234+
5. Viewers who are not logged in see a prominent call-to-action banner at the top inviting them to try Doing It.
235+
6. All interactive elements (search, session controls, delete buttons, later input) are hidden in shared view. Data is fetched from public `/shared/{token}/*` endpoints that require no authentication.
236+
237+
**API endpoints**
238+
239+
| Method | Path | Description |
240+
|--------|------|-------------|
241+
| `GET` | `/share/status` | Check if sharing is enabled and get token (auth required) |
242+
| `POST` | `/share/enable` | Generate or return existing share token (auth required) |
243+
| `POST` | `/share/disable` | Revoke share token (auth required) |
244+
| `GET` | `/shared/{token}/data` | Public: tasks, later items, theme, projects |
245+
| `GET` | `/shared/{token}/done` | Public: paginated done items |
246+
| `GET` | `/shared/{token}/done/stats` | Public: done stats and sparkline |
247+
| `GET` | `/shared/{token}/report/monthly` | Public: 30-day time report |
248+
249+
## Task ordering
250+
251+
Today's tasks are ordered by most recently finished session. When you stop a running task, it stays at the top of the list. Running tasks always appear first, followed by tasks sorted by their latest completed session timestamp (descending). This means the task you just worked on is always easy to find.
252+
253+
## Later list drag-and-drop
254+
255+
Later (to-do) items can be reordered by dragging. Hover over an item to reveal the drag handle on the left (same position as the shortcut numbers in the task list). Drag an item up or down to change its position. New items are added at the top of the list.
256+
224257
## Data
225258

226259
All task data is stored per-user in a Postgres database. Locally this is the `tt` database on your Postgres.app instance. In production it's the Fly.io Postgres cluster attached to the app.

app.py

Lines changed: 185 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ def init_db():
108108
cur.execute("ALTER TABLE users ADD COLUMN IF NOT EXISTS trial_started_at TIMESTAMPTZ")
109109
cur.execute("ALTER TABLE users ADD COLUMN IF NOT EXISTS is_comped BOOLEAN DEFAULT FALSE")
110110
cur.execute("ALTER TABLE users ADD COLUMN IF NOT EXISTS theme TEXT")
111+
cur.execute("ALTER TABLE users ADD COLUMN IF NOT EXISTS share_token TEXT UNIQUE")
111112

112113
# ── New normalized tables ────────────────────────────────
113114
cur.execute("""
@@ -242,6 +243,14 @@ def make_token(user_id: int) -> str:
242243
return jwt.encode({"sub": str(user_id), "exp": expire}, SECRET_KEY, algorithm=ALGORITHM)
243244

244245

246+
def user_id_from_share_token(token: str, db) -> int:
247+
db.execute("SELECT id FROM users WHERE share_token = %s", (token,))
248+
row = db.fetchone()
249+
if not row:
250+
raise HTTPException(status_code=404, detail="Shared profile not found")
251+
return row["id"]
252+
253+
245254
class AuthRequest(BaseModel):
246255
email: str
247256
password: str
@@ -394,11 +403,7 @@ def merge_projects_from_blob(tasks: list, blob_json: str | None) -> tuple[list,
394403
return tasks, projects
395404

396405

397-
@app.get("/data")
398-
def get_data(
399-
user_id: Annotated[int, Depends(current_user_id)],
400-
db: Annotated[psycopg2.extensions.cursor, Depends(get_db)],
401-
):
406+
def _fetch_data(user_id: int, db):
402407
db.execute("""
403408
SELECT
404409
t.id,
@@ -436,7 +441,15 @@ def get_data(
436441
blob_json = blob_row["tasks_json"] if blob_row else None
437442
tasks, projects = merge_projects_from_blob(tasks, blob_json)
438443

439-
return JSONResponse({"tasks": tasks, "later": later, "theme": theme, "projects": projects})
444+
return {"tasks": tasks, "later": later, "theme": theme, "projects": projects}
445+
446+
447+
@app.get("/data")
448+
def get_data(
449+
user_id: Annotated[int, Depends(current_user_id)],
450+
db: Annotated[psycopg2.extensions.cursor, Depends(get_db)],
451+
):
452+
return JSONResponse(_fetch_data(user_id, db))
440453

441454

442455
@app.post("/data", status_code=204)
@@ -557,13 +570,7 @@ def mark_done(
557570
return {"ok": True}
558571

559572

560-
@app.get("/done")
561-
def get_done(
562-
user_id: Annotated[int, Depends(current_user_id)],
563-
db: Annotated[psycopg2.extensions.cursor, Depends(get_db)],
564-
offset: int = 0,
565-
limit: int = 50,
566-
):
573+
def _fetch_done(user_id: int, db, offset: int = 0, limit: int = 50):
567574
limit = min(limit, 100)
568575
db.execute(
569576
"SELECT id, text, done_at FROM done_items "
@@ -579,11 +586,17 @@ def get_done(
579586
return {"items": items, "total": total}
580587

581588

582-
@app.get("/done/stats")
583-
def done_stats(
589+
@app.get("/done")
590+
def get_done(
584591
user_id: Annotated[int, Depends(current_user_id)],
585592
db: Annotated[psycopg2.extensions.cursor, Depends(get_db)],
593+
offset: int = 0,
594+
limit: int = 50,
586595
):
596+
return _fetch_done(user_id, db, offset, limit)
597+
598+
599+
def _fetch_done_stats(user_id: int, db):
587600
# Done this week (Monday-based)
588601
db.execute("""
589602
SELECT COUNT(*) AS cnt FROM done_items
@@ -598,21 +611,152 @@ def done_stats(
598611
""", (user_id,))
599612
this_month = db.fetchone()["cnt"]
600613

601-
# Average per week over last 10 weeks
614+
# Weeks since signup (capped at 10)
602615
db.execute("""
603-
SELECT COUNT(*) AS cnt FROM done_items
604-
WHERE user_id = %s AND done_at >= NOW() - INTERVAL '10 weeks'
616+
SELECT GREATEST(1, LEAST(10,
617+
CEIL(EXTRACT(EPOCH FROM NOW() - created_at) / (7*86400))
618+
))::int AS weeks
619+
FROM users WHERE id = %s
605620
""", (user_id,))
606-
total_10w = db.fetchone()["cnt"]
607-
avg_per_week = round(total_10w / 10, 1)
621+
max_weeks = db.fetchone()["weeks"]
622+
623+
# Weekly counts for sparkline (most recent max_weeks, oldest first)
624+
db.execute("""
625+
SELECT date_trunc('week', done_at) AS wk, COUNT(*) AS cnt
626+
FROM done_items
627+
WHERE user_id = %s AND done_at >= NOW() - make_interval(weeks => %s)
628+
GROUP BY wk ORDER BY wk
629+
""", (user_id, max_weeks))
630+
rows = {r["wk"]: r["cnt"] for r in db.fetchall()}
631+
632+
# Build array of counts per week (oldest first)
633+
from datetime import timedelta
634+
now_trunc = db.execute("SELECT date_trunc('week', NOW()) AS wk")
635+
current_wk = db.fetchone()["wk"]
636+
weekly = []
637+
for i in range(max_weeks - 1, -1, -1):
638+
wk = current_wk - timedelta(weeks=i)
639+
weekly.append(rows.get(wk, 0))
640+
641+
# Average per week over last 4 weeks (or fewer if signed up recently)
642+
avg_weeks = min(4, max_weeks)
643+
avg_total = sum(weekly[-avg_weeks:])
644+
avg_per_week = round(avg_total / avg_weeks, 1)
608645

609646
return {
610-
"this_week": this_week,
611647
"this_month": this_month,
648+
"this_week": this_week,
612649
"avg_per_week": avg_per_week,
650+
"avg_weeks": avg_weeks,
651+
"weekly": weekly,
613652
}
614653

615654

655+
@app.get("/done/stats")
656+
def done_stats(
657+
user_id: Annotated[int, Depends(current_user_id)],
658+
db: Annotated[psycopg2.extensions.cursor, Depends(get_db)],
659+
):
660+
return _fetch_done_stats(user_id, db)
661+
662+
663+
def _fetch_monthly_report(user_id: int, db):
664+
thirty_days_ago_ms = int((time.time() - 30 * 86400) * 1000)
665+
db.execute("""
666+
SELECT t.name,
667+
SUM(s.end_ts - s.start_ts) AS total_ms,
668+
COUNT(*) AS session_count
669+
FROM sessions s
670+
JOIN tasks t ON t.id = s.task_id AND t.user_id = s.user_id
671+
WHERE s.user_id = %s
672+
AND s.start_ts >= %s
673+
AND s.end_ts IS NOT NULL
674+
GROUP BY t.name
675+
ORDER BY total_ms DESC
676+
""", (user_id, thirty_days_ago_ms))
677+
tasks = [
678+
{"name": r["name"], "total_ms": int(r["total_ms"]), "session_count": r["session_count"]}
679+
for r in db.fetchall()
680+
]
681+
total_ms = sum(t["total_ms"] for t in tasks)
682+
period_start = datetime.fromtimestamp(thirty_days_ago_ms / 1000, tz=timezone.utc).strftime("%Y-%m-%d")
683+
period_end = datetime.now(timezone.utc).strftime("%Y-%m-%d")
684+
return {"tasks": tasks, "total_ms": total_ms, "period_start": period_start, "period_end": period_end}
685+
686+
687+
@app.get("/report/monthly")
688+
def monthly_report(
689+
user_id: Annotated[int, Depends(current_user_id)],
690+
db: Annotated[psycopg2.extensions.cursor, Depends(get_db)],
691+
):
692+
return _fetch_monthly_report(user_id, db)
693+
694+
695+
# ── Share endpoints ──────────────────────────────────────────────────────────
696+
697+
@app.get("/share/status")
698+
def share_status(
699+
user_id: Annotated[int, Depends(current_user_id)],
700+
db: Annotated[psycopg2.extensions.cursor, Depends(get_db)],
701+
):
702+
db.execute("SELECT share_token FROM users WHERE id = %s", (user_id,))
703+
row = db.fetchone()
704+
token = row["share_token"] if row else None
705+
return {"enabled": bool(token), "share_token": token}
706+
707+
708+
@app.post("/share/enable")
709+
def share_enable(
710+
user_id: Annotated[int, Depends(current_user_id)],
711+
db: Annotated[psycopg2.extensions.cursor, Depends(get_db)],
712+
):
713+
db.execute("SELECT share_token FROM users WHERE id = %s", (user_id,))
714+
row = db.fetchone()
715+
if row and row["share_token"]:
716+
return {"share_token": row["share_token"]}
717+
token = str(uuid_mod.uuid4())
718+
db.execute("UPDATE users SET share_token = %s WHERE id = %s", (token, user_id))
719+
return {"share_token": token}
720+
721+
722+
@app.post("/share/disable")
723+
def share_disable(
724+
user_id: Annotated[int, Depends(current_user_id)],
725+
db: Annotated[psycopg2.extensions.cursor, Depends(get_db)],
726+
):
727+
db.execute("UPDATE users SET share_token = NULL WHERE id = %s", (user_id,))
728+
return {"ok": True}
729+
730+
731+
@app.get("/shared/{token}/data")
732+
def shared_get_data(token: str, db: Annotated[psycopg2.extensions.cursor, Depends(get_db)]):
733+
uid = user_id_from_share_token(token, db)
734+
return JSONResponse(_fetch_data(uid, db))
735+
736+
737+
@app.get("/shared/{token}/done")
738+
def shared_get_done(
739+
token: str,
740+
db: Annotated[psycopg2.extensions.cursor, Depends(get_db)],
741+
offset: int = 0,
742+
limit: int = 50,
743+
):
744+
uid = user_id_from_share_token(token, db)
745+
return _fetch_done(uid, db, offset, limit)
746+
747+
748+
@app.get("/shared/{token}/done/stats")
749+
def shared_done_stats(token: str, db: Annotated[psycopg2.extensions.cursor, Depends(get_db)]):
750+
uid = user_id_from_share_token(token, db)
751+
return _fetch_done_stats(uid, db)
752+
753+
754+
@app.get("/shared/{token}/report/monthly")
755+
def shared_monthly_report(token: str, db: Annotated[psycopg2.extensions.cursor, Depends(get_db)]):
756+
uid = user_id_from_share_token(token, db)
757+
return _fetch_monthly_report(uid, db)
758+
759+
616760
def count_today_sessions(user_id: int, db) -> int:
617761
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
618762
db.execute("""
@@ -777,11 +921,31 @@ def favicon_local():
777921
return FileResponse("favicon-local.png", media_type="image/png")
778922

779923

924+
@app.get("/shared/{token}")
925+
def shared_root(token: str):
926+
return FileResponse("index.html")
927+
928+
929+
@app.get("/shared/{token}/done-list")
930+
def shared_done_page(token: str):
931+
return FileResponse("index.html")
932+
933+
934+
@app.get("/shared/{token}/report")
935+
def shared_report_page(token: str):
936+
return FileResponse("index.html")
937+
938+
780939
@app.get("/done-list")
781940
def done_page():
782941
return FileResponse("index.html")
783942

784943

944+
@app.get("/report")
945+
def report_page():
946+
return FileResponse("index.html")
947+
948+
785949
@app.get("/")
786950
def root():
787951
return FileResponse("index.html")

index.html

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,23 @@
9696
<div id="app">
9797

9898
<div class="theme-bar">
99+
<button class="header-share-live" id="header-share" style="display:none" title="Share a live URL so others can follow your progress in real time">share live view</button>
100+
<span class="header-sep theme-bar-sep" id="theme-bar-sep" style="display:none">/</span>
101+
<button class="header-logout-btn" id="header-logout" style="display:none">logout</button>
102+
<span class="header-sep theme-bar-sep" id="theme-bar-sep2" style="display:none">/</span>
103+
<button class="header-signin-btn" id="header-signin" style="display:none">sign in</button>
99104
<button class="header-theme" id="theme-toggle" title="Toggle theme"></button>
100105
</div>
106+
<div class="share-popover" id="share-popover" style="display:none">
107+
<div class="share-popover-row">
108+
<span class="share-popover-label">Live sharing</span>
109+
<button class="share-popover-toggle" id="share-toggle">Enable</button>
110+
</div>
111+
<div class="share-popover-link-row" id="share-link-row" style="display:none">
112+
<input class="share-popover-url" id="share-url" readonly>
113+
<button class="share-popover-copy" id="share-copy">Copy</button>
114+
</div>
115+
</div>
101116

102117
<div class="header">
103118
<span class="header-logo">Doing It</span>
@@ -108,8 +123,6 @@
108123
<button class="header-pomodoro" id="header-pomodoro" title="Toggle pomodoro">🍅</button>
109124
<input class="header-pomodoro-mins" id="header-pomodoro-mins" type="number" min="1" max="60" value="25" title="Pomodoro minutes">
110125
<span class="header-sep">/</span>
111-
<button class="header-logout" id="header-signin" style="display:none">sign in</button>
112-
<button class="header-logout" id="header-logout" style="display:none">logout</button>
113126
<button class="header-logout header-upgrade" id="header-upgrade" style="display:none">upgrade: it’s $1.49 a month…</button>
114127
<button class="header-logout header-manage" id="header-manage" style="display:none">pro ✓</button>
115128
<span class="header-vip" id="header-vip" style="display:none">♛ vip</span>
@@ -145,12 +158,13 @@
145158
<ul id="task-list"></ul>
146159

147160
<div id="history"></div>
161+
<a id="report-link" class="report-link" href="/report">Monthly report →</a>
148162

149163
<section id="later">
150164
<div id="later-header">later</div>
151165
<input id="later-input" type="text" placeholder="something for later… (Shift+N)" autocomplete="off">
152166
<ul id="later-list"></ul>
153-
<a id="later-done-link" href="/done-list">See all Done</a>
167+
<a id="later-done-link" href="/done-list">See all done →</a>
154168
</section>
155169

156170
</div>

0 commit comments

Comments
 (0)