2026-11-05 00:42:58 -05:00
import { exportForestCsv , importForestCsv } from "./csv.ts" ;
import { overlapMs , rangeWindow , totals , type RangeKind } from "./analytics.ts" ;
import { allRecords , saveRecord , saveRecords } from "./store.ts" ;
import { syncNow , type SyncStatus } from "./sync.ts" ;
import { dueMs , recordEnd , type TimeRecord } from "../shared/record.ts" ;
import { normalizeTag } from "../shared/tag.ts" ;
import { countdown , pad , pageTitle } from "./title.ts" ;
2026-11-08 14:49:29 -05:00
import { readyToReload } from "./update.ts" ;
2026-11-05 00:42:58 -05:00
type View = "timer" | "log" | "insights" | "settings" ;
type Theme = "latte" | "mocha" ;
type Me = { authenticated : boolean ; dev : boolean ; githubConfigured : boolean ; vapidPublicKey : string | null };
const app = document . getElementById ( "app" ) ! ;
let records : TimeRecord [] = [];
let view : View = "timer" ;
let theme : Theme = document . documentElement . dataset . theme === "latte" ? "latte" : "mocha" ;
let me : Me | null = null ;
let syncStatus : SyncStatus = navigator . onLine ? "pending" : "offline" ;
let selectedTag = normalizeTag ( localStorage . getItem ( "timelog-last-tag" ) || "Study" );
let selectedDuration = Number ( localStorage . getItem ( "timelog-last-duration" ) || 25 );
let logMonthOffset = 0 ;
let logTag = "all" ;
let insightKind : RangeKind = "week" ;
let insightOffset = 0 ;
let editingId : string | null = null ;
let message : { text : string ; kind : "success" | "error" } | null = null ;
let toastTimer = 0 ;
let pushRegistration : ServiceWorkerRegistration | null = null ;
2026-11-08 14:49:29 -05:00
let updateReady = false ;
let busyCount = 0 ;
2026-11-05 00:42:58 -05:00
function h ( value : unknown ) : string {
return String ( value ?? "" ). replace ( /[&<>"']/g , char => ({ "&" : "&" , "<" : "<" , ">" : ">" , '"' : """ , "'" : "'" })[ char ] ! );
}
function clock ( ms : number ) { const d = new Date ( ms ); return ` ${ pad ( d . getHours ()) } : ${ pad ( d . getMinutes ()) } ` ; }
function dateLabel ( ms : number ) { return new Date ( ms ). toLocaleDateString ( undefined , { weekday : "long" , month : "long" , day : "numeric" , year : "numeric" }); }
function shortDate ( ms : number ) { return new Date ( ms ). toLocaleDateString ( undefined , { month : "short" , day : "numeric" }); }
function duration ( ms : number ) {
if ( ms <= 0 ) return "0m" ;
const minutes = Math . round ( ms / 60000 );
if ( minutes <= 0 ) return "<1m" ;
const hours = Math . floor ( minutes / 60 );
const rest = minutes % 60 ;
return hours ? ` ${ hours } h ${ rest ? ` ${ rest } m` : "" } ` : ` ${ rest } m` ;
}
function datetimeLocal ( ms : number ) {
const d = new Date ( ms );
return ` ${ d . getFullYear () } - ${ pad ( d . getMonth () + 1 ) } - ${ pad ( d . getDate ()) } T ${ pad ( d . getHours ()) } : ${ pad ( d . getMinutes ()) } : ${ pad ( d . getSeconds ()) } ` ;
}
function tags() {
return [... new Set ([ selectedTag , "Study" , ... records . filter ( r => ! r . deleted ). map ( r => r . tag )])]. sort (( a , b ) => a . localeCompare ( b ));
}
function activeRecord() { return records . find ( r => ! r . deleted && r . endMs === null ) || null ; }
function progress ( r : TimeRecord ) { return Math . min ( 100 , Math . max ( 0 , ( Date . now () - r . startMs ) / ( dueMs ( r ) - r . startMs ) * 100 )); }
function statusText() {
return ({ offline : "OFFLINE · SAVED HERE" , syncing : "SYNCING..." , synced : "ALL SYNCED" , pending : "SYNC PENDING" , "signed-out" : "SIGN IN TO SYNC" } as const )[ syncStatus ];
}
function markSignedOut() {
me = { ...( me || { dev : false , githubConfigured : true , vapidPublicKey : null }), authenticated : false };
localStorage . removeItem ( "timelog-auth-seen" );
syncStatus = "signed-out" ;
}
function setTheme ( next : Theme ) {
theme = next ;
document . documentElement . dataset . theme = next ;
document . querySelector ( 'meta[name="theme-color"]' ) ? . setAttribute ( "content" , next === "latte" ? "#e6e9ef" : "#1e1e2e" );
localStorage . setItem ( "timelog-theme" , next );
render ();
}
function showMessage ( text : string , kind : "success" | "error" = "success" ) {
message = { text , kind };
document . querySelector ( ".toast" ) ? . remove ();
const toast = document . createElement ( "div" );
toast . className = "toast" ;
toast . setAttribute ( "role" , "status" );
toast . textContent = text ;
app . appendChild ( toast );
clearTimeout ( toastTimer );
toastTimer = window . setTimeout (() => { message = null ; document . querySelector ( ".toast" ) ? . remove (); }, 5000 );
}
2026-11-08 14:49:29 -05:00
/** Marks work that a reload must not interrupt, such as a save or an import. */
async function busy < T >( work : () => Promise < T >) : Promise < T > {
busyCount ++ ;
try { return await work (); } finally { busyCount -- ; }
}
function applyUpdateIfSafe() {
if ( readyToReload ({ updateReady , hidden : document.hidden , editing : editingId !== null , busy : busyCount > 0 })) location . reload ();
}
function checkForUpdate() {
void pushRegistration ? . update (). catch (() => {});
}
2026-11-05 00:42:58 -05:00
function navButton ( target : View , label : string , index : string ) {
return `<button class="nav-btn ${ view === target ? "active" : "" } " data-action="nav" data-view=" ${ target } " type="button"><span class="nav-prefix"> ${ index } </span> ${ label } </button>` ;
}
function navButtons() {
return ` ${ navButton ( "timer" , "Timer" , "01" ) }${ navButton ( "log" , "Log" , "02" ) }${ navButton ( "insights" , "Insights" , "03" ) } ` ;
}
function frame ( content : string ) {
const today = new Date (). toLocaleDateString ( undefined , { weekday : "long" , month : "short" , day : "numeric" , year : "numeric" });
return `<div class="shell">
<aside class="sidebar">
<div class="brand"><span class="brand-mark">TL</span><span class="brand-name">timelog</span></div>
<nav class="nav" aria-label="Main navigation">
${ navButtons () }
</nav>
<div class="side-bottom"><div class="status-row"><span class="status-dot ${ syncStatus } "></span> ${ statusText () } </div></div>
</aside>
<main class="main">
2026-11-08 14:49:29 -05:00
<header class="topbar"><span class="topbar-date"> ${ h ( today ) } </span><div class="topbar-actions"> ${ updateReady ? `<button class="update-btn" data-action="reload-update" title="Reload into the new version. A running timer continues." type="button">UPDATE</button>` : "" } <span class="topbar-status"> ${ statusText () } </span><button class="icon-btn" title="Settings" aria-label="Settings" data-action="nav" data-view="settings" type="button">⚙</button></div></header>
2026-11-05 00:42:58 -05:00
${ content }
</main>
<nav class="mobile-nav" aria-label="Mobile navigation"> ${ navButtons () } </nav>
</div> ${ editingId ? editorModal () : "" }${ message ? `<div class="toast" role="status"> ${ h ( message . text ) } </div>` : "" } ` ;
}
function loginPage() {
const offline = ! navigator . onLine ;
return `<div class="login"><div class="login-card"><div class="brand"><span class="brand-mark">TL</span><span class="brand-name">timelog</span></div>
<h1>Time, plainly.</h1><p>One timer. One log. A clear view of where the hours went.</p>
${ offline ? `<div class="notice">You're offline. Sign in once while connected to use this device offline.</div>` : "" }
${ me && ! me . githubConfigured ? `<div class="notice error">GitHub sign-in has not been configured on the server yet.</div>` : "" }
<a class="primary-btn" href="/auth/github">> SIGN IN WITH GITHUB</a><p style="margin-top:22px">Private to your account. No repository access.</p>
</div></div>` ;
}
function pageHeading ( title : string , meta = "" , eyebrow = "" ) {
return `<div class="page-heading"><div> ${ eyebrow ? `<p class="eyebrow"> ${ eyebrow } </p>` : "" } <h1> ${ title } </h1></div> ${ meta ? `<span class="heading-meta"> ${ meta } </span>` : "" } </div>` ;
}
function timerPage() {
const active = activeRecord ();
const todayWindow = rangeWindow ( "day" );
const today = totals ( records , todayWindow );
const completed = records . filter ( r => ! r . deleted && r . endMs !== null && overlapMs ( r , todayWindow . start , todayWindow . end ) > 0 );
const completedToday = completed . length ;
const week = totals ( records , rangeWindow ( "week" ));
const tagOptions = tags (). map ( tag => `<option value=" ${ h ( tag ) } " ${ tag === selectedTag ? "selected" : "" } > ${ h ( tag ) } </option>` ). join ( "" );
const latest = completed . sort (( a , b ) => b . startMs - a . startMs ). slice ( 0 , 6 );
const weekMax = Math . max (... week . trend , 1 );
return `<section class="page">
<div class="grid">
<div class="panel"><div class="panel-inner">
${ active ? `<div class="panel-label"><span>● SESSION IN PROGRESS</span><span class="end"> ${ h ( active . tag . toUpperCase ()) } </span></div>` : "" }
<div class="timer-display ${ active ? "" : "idle" } " id="timer-display"> ${ active ? countdown ( dueMs ( active ) - Date . now ()) : ` ${ pad ( selectedDuration ) } <span class="seconds">:00</span>` } </div>
${ active ? `<div class="timer-hint" id="timer-hint">Started ${ clock ( active . startMs ) } · Ends ${ clock ( dueMs ( active )) } </div>` : "" }
<div class="progress-track"><div class="progress-fill" id="progress-fill" style="width: ${ active ? progress ( active ) : 0 } %"></div></div>
${ active ? `<div class="field-grid"><div><span class="field-label">TAG</span><div class="field" style="display:flex;align-items:center"> ${ h ( active . tag ) } </div></div><div><span class="field-label">DURATION</span><div class="field" style="display:flex;align-items:center"> ${ duration ( active . targetSeconds ! * 1000 ) } </div></div></div>
<button class="primary-btn outline" data-action="stop" type="button">■ STOP EARLY & LOG TIME</button>` : `
<div class="field-grid"><div><label class="field-label" for="timer-tag">TAG</label><select class="field" id="timer-tag"> ${ tagOptions } <option value="__new__">+ New tag...</option></select></div>
<div><label class="field-label" for="timer-duration">DURATION / MIN</label><input class="field" id="timer-duration" type="number" min="1" max="180" inputmode="numeric" value=" ${ selectedDuration } " /></div></div>
<div class="duration-row"> ${ [ 15 , 25 , 30 , 45 , 60 , 120 ]. map ( n => `<button class="chip ${ n === selectedDuration ? "active" : "" } " data-action="duration" data-minutes=" ${ n } " type="button"> ${ n } m</button>` ). join ( "" ) } </div>
<button class="primary-btn" data-action="start" type="button">> START SESSION</button>` }
</div></div>
<div class="panel"><div class="panel-inner"><div class="panel-label"><span>// TODAY</span><span class="end"> ${ h ( shortDate ( todayWindow . start ). toUpperCase ()) } </span></div>
<div class="side-stat" id="today-total"> ${ duration ( today . totalMs ) } </div><div class="muted" style="font-size:12px"> ${ completedToday } ${ completedToday === 1 ? "session" : "sessions" } logged ${ active ? " · 1 running" : "" } </div>
<div class="dash"></div><div class="panel-label"><span>THIS WEEK</span><span class="end"> ${ duration ( week . totalMs ) } </span></div>
<div class="mini-bars"> ${ week . trend . map (( value , i ) => `<div class="mini-bar ${ i === (( new Date (). getDay () + 6 ) % 7 ) ? "current" : "" } " title=" ${ duration ( value ) } " style="height: ${ Math . max ( 3 , value / weekMax * 100 ) } %"></div>` ). join ( "" ) } </div>
<div class="muted" style="display:flex;justify-content:space-between;font-size:10px;margin-top:8px"><span>MON</span><span>SUN</span></div>
</div></div>
</div>
<section class="section"><div class="section-head"><h2>Today's log</h2><button class="text-btn" data-action="nav" data-view="log" type="button">VIEW ALL →</button></div>
${ latest . length ? latest . map ( record => entryRow ( record )). join ( "" ) : `<div class="empty">No time logged today. Start when you're ready.</div>` }
</section>
</section>` ;
}
function entryRow ( record : TimeRecord , editable = false ) {
const end = recordEnd ( record );
return `<div class="entry ${ editable ? "log-entry" : "" } "><span class="entry-time"> ${ clock ( record . startMs ) } – ${ record . endMs === null ? "NOW" : clock ( end ) } </span>
<span class="entry-tag"> ${ h ( record . tag ) }${ editable && record . note ? `<small> ${ h ( record . note ) } </small>` : "" } </span>
<span class="entry-duration"> ${ duration ( end - record . startMs ) } </span>
${ editable ? `<button class="entry-btn" data-action="edit" data-id=" ${ h ( record . id ) } " aria-label="Edit ${ h ( record . tag ) } session" type="button">✎</button>` : `<span class="entry-status" title=" ${ record . isSuccess === false ? "Stopped early or unsuccessful" : "Complete" } "> ${ record . isSuccess === false ? "○" : "·" } </span>` }
</div>` ;
}
function monthDate ( offset : number ) { const now = new Date (); return new Date ( now . getFullYear (), now . getMonth () + offset , 1 ); }
function logPage() {
const month = monthDate ( logMonthOffset );
const nextMonth = new Date ( month . getFullYear (), month . getMonth () + 1 , 1 );
const filtered = records . filter ( r => ! r . deleted && r . startMs >= + month && r . startMs < + nextMonth && ( logTag === "all" || r . tag === logTag ))
. sort (( a , b ) => b . startMs - a . startMs );
const groups = new Map < string , TimeRecord [] >();
for ( const record of filtered ) {
const key = new Date ( record . startMs ). toDateString ();
if ( ! groups . has ( key )) groups . set ( key , []);
groups . get ( key ) ! . push ( record );
}
const tagOptions = tags (). map ( tag => `<option value=" ${ h ( tag ) } " ${ tag === logTag ? "selected" : "" } > ${ h ( tag ) } </option>` ). join ( "" );
return `<section class="page"> ${ pageHeading ( "Your time, in order." , ` ${ filtered . length } ENTRIES` ) }
<div class="toolbar"><button class="small-btn" data-action="prev-log" type="button" aria-label="Previous month">←</button>
<span class="range-label"> ${ h ( month . toLocaleDateString ( undefined , { month : "long" , year : "numeric" } ).toUpperCase())}</span>
<button class="small-btn" data-action="next-log" type="button" aria-label="Next month">→</button>
<select class="field" id="log-tag" aria-label="Filter by tag"><option value="all">ALL TAGS</option> ${ tagOptions } </select>
<span class="spacer"></span><button class="small-btn" data-action="add" type="button">+ ADD ENTRY</button></div>
${ groups . size ? [... groups ]. map (([ label , entries ]) => `<div class="log-group"><div class="log-date"><span> ${ h ( label . toUpperCase ()) } </span><span> ${ duration ( entries . reduce (( sum , record ) => sum + Math . max ( 0 , recordEnd ( record ) - record . startMs ), 0 )) } </span></div> ${ entries . map ( record => entryRow ( record , true )). join ( "" ) } </div>` ). join ( "" ) : `<div class="empty">No entries for this month ${ logTag !== "all" ? " and tag" : "" } .</div>` }
</section>` ;
}
function insightsPage() {
const range = rangeWindow ( insightKind , insightOffset );
const stats = totals ( records , range );
const max = Math . max (... stats . trend , 1 );
const maxTag = stats . tags [ 0 ] ? .[ 1 ] || 1 ;
return `<section class="page"> ${ pageHeading ( "Where time goes." ) }
<div class="toolbar range-controls"><div class="segmented" role="group" aria-label="Insight range"> ${ ([ "day" , "week" , "month" , "year" ] as RangeKind []). map ( kind => `<button class="segment ${ insightKind === kind ? "active" : "" } " data-action="range" data-range=" ${ kind } " type="button"> ${ kind } </button>` ). join ( "" ) } </div>
<span class="spacer"></span><button class="small-btn" data-action="prev-insight" type="button" aria-label="Previous period">←</button>
<span class="range-label"> ${ h ( range . label . toUpperCase ()) } </span><button class="small-btn" data-action="next-insight" type="button" aria-label="Next period">→</button></div>
<div class="insight-grid"><div class="panel"><div class="panel-inner"><div class="panel-label"><span>// TIME TREND</span><span class="end"> ${ h ( insightKind . toUpperCase ()) } </span></div>
<div class="insight-big"> ${ duration ( stats . totalMs ) } </div><div class="muted" style="font-size:11px"> ${ stats . sessionCount } ${ stats . sessionCount === 1 ? "session" : "sessions" } in this period</div>
<div class="trend-chart" role="img" aria-label="Logged time trend for ${ h ( range . label ) } "> ${ range . bins . map (( bin , index ) => {
const value = stats . trend [ index ] ! ;
const isCurrent = Date . now () >= bin . start && Date . now () < bin . end ;
return `<div class="trend-col ${ isCurrent ? "current" : "" } " title=" ${ h ( bin . label ) } : ${ duration ( value ) } "><span class="trend-value"> ${ value ? duration ( value ) : "" } </span><div class="trend-track"><div class="trend-fill" style="height: ${ Math . max ( 2 , value / max * 100 ) } %"></div></div><span class="trend-label"> ${ h ( bin . label ) } </span></div>` ;
} ).join("")}</div>
</div></div><div class="panel"><div class="panel-inner"><div class="panel-label"><span>// BY TAG</span><span class="end"> ${ stats . tags . length } CATEGORIES</span></div>
${ stats . tags . length ? stats . tags . map (([ tag , value ]) => `<div class="tag-row"><div class="tag-row-head"><span> ${ h ( tag ) } </span><span> ${ duration ( value ) } · ${ Math . round ( value / stats . totalMs * 100 ) } %</span></div><div class="tag-track"><div class="tag-fill" style="width: ${ Math . max ( 1 , value / maxTag * 100 ) } %"></div></div></div>` ). join ( "" ) : `<div class="empty">No time logged in this period.</div>` }
<p class="insight-note">Stopped-early sessions count for the time actually spent. Sessions crossing midnight are split between days.</p>
</div></div></div>
</section>` ;
}
function settingsPage() {
const canNotify = "Notification" in window && "serviceWorker" in navigator && "PushManager" in window ;
const permission = "Notification" in window ? Notification . permission : "unsupported" ;
return `<section class="page"> ${ pageHeading ( "Preferences." , "KEEP IT SIMPLE" , "// SYSTEM" ) }
<div class="panel"><div class="panel-inner"><div class="panel-label"><span>// DATA & DEVICE</span><span class="end">PRIVATE</span></div>
<div class="settings-row"><div><h3>Appearance</h3><p>Catppuccin colors. Saved on this device.</p></div><div class="theme-toggle" role="group" aria-label="Color theme"><button class="theme-option ${ theme === "latte" ? "active" : "" } " data-action="theme" data-theme="latte" aria-pressed=" ${ theme === "latte" } " type="button">LATTE</button><button class="theme-option ${ theme === "mocha" ? "active" : "" } " data-action="theme" data-theme="mocha" aria-pressed=" ${ theme === "mocha" } " type="button">MOCHA</button></div></div>
<div class="settings-row"><div><h3>Forest CSV import</h3><p>Bring in your existing sessions. Re-importing the same file skips duplicates.</p></div><button class="small-btn" data-action="import" type="button">IMPORT CSV</button><input class="hidden-input" id="csv-file" type="file" accept=".csv,text/csv" /></div>
<div class="settings-row"><div><h3>Forest CSV export</h3><p>Download your completed sessions with Forest's six-column layout.</p></div><button class="small-btn" data-action="export" type="button">EXPORT CSV</button></div>
<div class="settings-row"><div><h3>Completion notifications</h3><p> ${ permission === "denied" ? "Blocked in your device settings." : ! canNotify ? "On iPhone, add Timelog to your Home Screen, then open it there to enable alerts." : permission === "granted" ? "Permission granted on this device. Reconnect if alerts stop arriving." : "Get an alert when a session reaches its duration. On iPhone, open Timelog from your Home Screen first." } </p></div><button class="small-btn" data-action="notify" type="button" ${ ! canNotify || ! pushRegistration || permission === "denied" ? "disabled" : "" } > ${ permission === "granted" ? "RECONNECT" : "ENABLE" } </button></div>
<div class="settings-row"><div><h3>Sync</h3><p> ${ statusText () } . Sessions remain here until the server can receive them.</p></div><button class="small-btn" data-action="sync" type="button">SYNC NOW</button></div>
<div class="settings-row"><div><h3>Account</h3><p> ${ me ? . dev ? "Local development mode" : "Signed in with your GitHub account" } </p></div><button class="small-btn" data-action="logout" type="button">SIGN OUT</button></div>
</div></div>
</section>` ;
}
function editorModal() {
const existing = records . find ( r => r . id === editingId );
const start = existing ? . startMs || Date . now () - 25 * 60000 ;
const end = existing ? . endMs || Date . now ();
return `<div class="modal-backdrop"><div class="modal" role="dialog" aria-modal="true" aria-label=" ${ existing ? "Edit" : "Add" } log entry">
<div class="modal-head"><h2> ${ existing ? "EDIT ENTRY" : "ADD ENTRY" } </h2><button class="icon-btn" data-action="close-modal" aria-label="Close" type="button">× </button></div>
<label class="field-label" for="edit-tag">TAG</label><input class="field" id="edit-tag" maxlength="100" value=" ${ h ( existing ? . tag || selectedTag ) } " list="known-tags" />
<datalist id="known-tags"> ${ tags (). map ( tag => `<option value=" ${ h ( tag ) } "></option>` ). join ( "" ) } </datalist>
<label class="field-label" for="edit-start">START</label><input class="field" id="edit-start" type="datetime-local" step="1" value=" ${ datetimeLocal ( start ) } " />
<label class="field-label" for="edit-end">END</label><input class="field" id="edit-end" type="datetime-local" step="1" value=" ${ datetimeLocal ( end ) } " />
<label class="field-label" for="edit-note">NOTE / OPTIONAL</label><input class="field" id="edit-note" maxlength="2000" value=" ${ h ( existing ? . note || "" ) } " />
<div class="modal-actions"><button class="small-btn" data-action="save-edit" type="button">SAVE ENTRY</button> ${ existing ? `<button class="small-btn danger" data-action="delete-edit" type="button">DELETE</button>` : "" } </div>
</div></div>` ;
}
function render() {
if ( ! me ) {
app . innerHTML = `<div class="login"><div class="login-card"><div class="brand"><span class="brand-mark">TL</span><span class="brand-name">timelog</span></div><p style="margin-top:32px">Opening your log...</p></div></div>` ;
return ;
}
if ( me && ! me . authenticated && ! ( localStorage . getItem ( "timelog-auth-seen" ) && ! navigator . onLine )) {
app . innerHTML = loginPage (); return ;
}
updateTitle ();
const content = view === "timer" ? timerPage () : view === "log" ? logPage () : view === "insights" ? insightsPage () : settingsPage ();
app . innerHTML = frame ( content );
}
function updateTitle() {
const next = pageTitle ( activeRecord ());
if ( document . title !== next ) document . title = next ;
}
function refreshFromStore() { return allRecords (). then ( all => { records = all ; render (); }); }
function sync() {
void syncNow (( status , updated ) => {
syncStatus = status ;
if ( updated ) records = updated ;
if ( status === "signed-out" ) markSignedOut ();
render ();
});
}
async function notifyFinished ( record : TimeRecord ) {
if ( ! ( "Notification" in window ) || Notification . permission !== "granted" || ! ( "serviceWorker" in navigator )) return ;
try {
const registration = pushRegistration || await navigator . serviceWorker . ready ;
// The server already pushes completed sessions it knows about. Showing a
// local notification too produces duplicate alerts on the same device.
if ( record . revision && await registration . pushManager ? . getSubscription ()) return ;
await registration . showNotification ( "Session complete" , {
body : ` ${ record . tag } time is up.` , tag : `timelog- ${ record . id } ` , icon : "/icon-192.png" ,
});
} catch ( error ) { console . warn ( "Local notification failed" , error ); }
}
2026-11-08 14:49:29 -05:00
function settleDue() { return busy ( settleDueNow ); }
async function settleDueNow() {
2026-11-05 00:42:58 -05:00
const now = Date . now ();
const due = records . filter ( r => ! r . deleted && r . endMs === null && r . targetSeconds !== null && dueMs ( r ) <= now );
if ( ! due . length ) return ;
for ( const record of due ) {
record . endMs = dueMs ( record );
record . isSuccess = true ;
await saveRecord ( record );
void notifyFinished ( record );
}
showMessage ( due . length === 1 ? "Session complete. Time logged." : ` ${ due . length } sessions complete. Time logged.` );
sync ();
}
async function startSession() {
if ( activeRecord ()) return ;
const tag = normalizeTag ( selectedTag );
const minutes = Number (( document . getElementById ( "timer-duration" ) as HTMLInputElement | null ) ? . value ?? selectedDuration );
selectedDuration = minutes ;
if ( ! tag || ! Number . isInteger ( minutes ) || minutes < 1 || minutes > 180 ) { showMessage ( "Choose a tag and a duration from 1 to 180 minutes." , "error" ); return ; }
const record : TimeRecord = {
id : `session_ ${ crypto . randomUUID () } ` , startMs : Date.now (), endMs : null , targetSeconds : minutes * 60 ,
tag , note : "" , treeType : "" , isSuccess : null , source : "timer" , rawStart : null , rawEnd : null , deleted : false ,
};
localStorage . setItem ( "timelog-last-tag" , tag );
localStorage . setItem ( "timelog-last-duration" , String ( minutes ));
await saveRecord ( record );
await refreshFromStore ();
sync ();
}
async function stopSession() {
const record = activeRecord ();
if ( ! record ) return ;
const due = dueMs ( record );
record . endMs = Math . min ( Date . now (), due );
record . isSuccess = record . endMs >= due ;
await saveRecord ( record );
await refreshFromStore ();
showMessage ( `Logged ${ duration ( record . endMs - record . startMs ) } for ${ record . tag } .` );
sync ();
}
async function saveEditor() {
const tag = normalizeTag (( document . getElementById ( "edit-tag" ) as HTMLInputElement ). value );
const startMs = new Date (( document . getElementById ( "edit-start" ) as HTMLInputElement ). value ). getTime ();
const endMs = new Date (( document . getElementById ( "edit-end" ) as HTMLInputElement ). value ). getTime ();
const note = ( document . getElementById ( "edit-note" ) as HTMLInputElement ). value ;
if ( ! tag || ! Number . isFinite ( startMs ) || ! Number . isFinite ( endMs ) || endMs <= startMs ) {
showMessage ( "Enter a tag and valid start and end times." , "error" ); return ;
}
const existing = records . find ( r => r . id === editingId );
const record : TimeRecord = existing ? { ... existing } : {
id : `manual_ ${ crypto . randomUUID () } ` , startMs , endMs , targetSeconds : null , tag , note ,
treeType : "" , isSuccess : true , source : "manual" , rawStart : null , rawEnd : null , deleted : false ,
};
record . startMs = startMs ; record . endMs = endMs ; record . tag = tag ; record . note = note ;
record . rawStart = null ; record . rawEnd = null ;
if ( record . source === "forest" ) record . source = "manual" ;
if ( record . targetSeconds !== null ) record . isSuccess = endMs >= dueMs ( record );
await saveRecord ( record );
editingId = null ;
await refreshFromStore ();
showMessage ( "Entry saved." );
sync ();
}
async function deleteEditor() {
const record = records . find ( r => r . id === editingId );
if ( ! record || ! window . confirm ( "Delete this entry?" )) return ;
await saveRecord ({ ... record , deleted : true });
editingId = null ;
await refreshFromStore ();
showMessage ( "Entry deleted." );
sync ();
}
async function importCsvFile ( file : File ) {
try {
const incoming = await importForestCsv ( await file . text ());
const existing = new Set ( records . map ( r => r . id ));
const fresh = incoming . filter ( r => ! existing . has ( r . id ));
await saveRecords ( fresh );
await refreshFromStore ();
showMessage ( `Imported ${ fresh . length } sessions. ${ incoming . length - fresh . length } already in your log.` );
sync ();
} catch ( error ) { showMessage ( error instanceof Error ? error . message : "Import failed" , "error" ); }
}
function exportCsvFile() {
const blob = new Blob ([ exportForestCsv ( records )], { type : "text/csv;charset=utf-8" });
const url = URL . createObjectURL ( blob );
const link = document . createElement ( "a" );
link . href = url ;
link . download = `Timelog_ ${ new Date (). toISOString (). slice ( 0 , 10 ) } .csv` ;
link . click ();
window . setTimeout (() => URL . revokeObjectURL ( url ), 1000 );
}
function vapidBytes ( key : string ) {
const base64 = key . replace ( /-/g , "+" ). replace ( /_/g , "/" ) + "=" . repeat (( 4 - key . length % 4 ) % 4 );
return Uint8Array . from ( atob ( base64 ), char => char . charCodeAt ( 0 ));
}
async function enableNotifications() {
if ( ! ( "Notification" in window ) || ! pushRegistration ? . pushManager ) { showMessage ( "Open the installed app to enable notifications." , "error" ); return ; }
if ( ! me ? . vapidPublicKey ) { showMessage ( "Server push is not available right now." , "error" ); return ; }
try {
// Start the subscription from the tap so iOS can show its permission prompt.
const subscription = await pushRegistration . pushManager . subscribe ({ userVisibleOnly : true , applicationServerKey : vapidBytes ( me . vapidPublicKey ) });
const response = await fetch ( "/api/push/subscribe" , { method : "POST" , headers : { "Content-Type" : "application/json" }, body : JSON.stringify ( subscription ) });
if ( ! response . ok ) throw new Error ( "Could not connect notifications to the server" );
showMessage ( "Completion notifications enabled." );
} catch ( error ) { showMessage ( error instanceof Error ? error . message : "Could not enable notifications" , "error" ); }
}
async function dispatch ( action : string , element : HTMLElement ) {
switch ( action ) {
case "nav" : view = element . dataset . view as View ; editingId = null ; render (); break ;
case "duration" : selectedDuration = Number ( element . dataset . minutes ); render (); break ;
case "start" : await startSession (); break ;
case "stop" : await stopSession (); break ;
case "prev-log" : logMonthOffset -- ; render (); break ;
case "next-log" : logMonthOffset ++ ; render (); break ;
case "prev-insight" : insightOffset -- ; render (); break ;
case "next-insight" : insightOffset ++ ; render (); break ;
case "range" : insightKind = element . dataset . range as RangeKind ; insightOffset = 0 ; render (); break ;
case "theme" : setTheme ( element . dataset . theme === "latte" ? "latte" : "mocha" ); break ;
case "add" : editingId = "new" ; render (); break ;
case "edit" : editingId = element . dataset . id || null ; render (); break ;
case "close-modal" : editingId = null ; render (); break ;
case "save-edit" : await saveEditor (); break ;
case "delete-edit" : await deleteEditor (); break ;
case "import" : ( document . getElementById ( "csv-file" ) as HTMLInputElement ). click (); break ;
case "export" : exportCsvFile (); break ;
case "notify" : await enableNotifications (); break ;
case "sync" : sync (); break ;
case "logout" :
if ( navigator . onLine ) await fetch ( "/api/logout" , { method : "POST" });
markSignedOut (); render (); break ;
}
}
app . addEventListener ( "click" , event => {
const element = ( event . target as HTMLElement ). closest < HTMLElement >( "[data-action]" );
2026-11-08 14:49:29 -05:00
// Check this before busy(), which would count the click itself as running work.
if ( element ? . dataset . action === "reload-update" ) { if ( busyCount === 0 ) location . reload (); }
else if ( element ) void busy (() => dispatch ( element . dataset . action ! , element )). catch ( error => showMessage ( String ( error ), "error" ));
2026-11-05 00:42:58 -05:00
else if (( event . target as HTMLElement ). classList . contains ( "modal-backdrop" )) { editingId = null ; render (); }
});
app . addEventListener ( "change" , event => {
const target = event . target as HTMLInputElement | HTMLSelectElement ;
if ( target . id === "timer-tag" ) {
if ( target . value === "__new__" ) {
const answer = window . prompt ( "New tag name" ) ? . trim ();
if ( answer && answer . length <= 100 ) selectedTag = normalizeTag ( answer );
} else selectedTag = target . value ;
render ();
} else if ( target . id === "timer-duration" ) {
selectedDuration = Number ( target . value );
} else if ( target . id === "log-tag" ) {
logTag = target . value ; render ();
} else if ( target . id === "csv-file" ) {
const file = ( target as HTMLInputElement ). files ? .[ 0 ];
2026-11-08 14:49:29 -05:00
if ( file ) void busy (() => importCsvFile ( file ));
2026-11-05 00:42:58 -05:00
}
});
app . addEventListener ( "input" , event => {
const target = event . target as HTMLInputElement ;
if ( target . id !== "timer-duration" ) return ;
selectedDuration = Number ( target . value );
const display = document . getElementById ( "timer-display" );
if ( display ) display . textContent = Number . isFinite ( selectedDuration ) ? countdown ( selectedDuration * 60 _000 ) : "--:--" ;
document . querySelectorAll < HTMLButtonElement >( ".chip[data-minutes]" ). forEach ( chip => {
chip . classList . toggle ( "active" , Number ( chip . dataset . minutes ) === selectedDuration );
});
});
window . addEventListener ( "online" , () => { syncStatus = "pending" ; sync (); });
window . addEventListener ( "offline" , () => { syncStatus = "offline" ; render (); });
2026-11-08 14:49:29 -05:00
document . addEventListener ( "visibilitychange" , () => {
if ( document . hidden ) applyUpdateIfSafe ();
else { void settleDue (); sync (); checkForUpdate (); }
});
2026-11-05 00:42:58 -05:00
document . addEventListener ( "keydown" , event => { if ( event . key === "Escape" && editingId ) { editingId = null ; render (); } });
setInterval (() => {
2026-11-08 14:49:29 -05:00
// Retry here, because a save can block the reload at the moment the page hides.
if ( document . hidden ) { applyUpdateIfSafe (); return ; }
2026-11-05 00:42:58 -05:00
void settleDue ();
updateTitle ();
const active = activeRecord ();
if ( ! active || view !== "timer" ) return ;
const display = document . getElementById ( "timer-display" );
if ( display ) display . textContent = countdown ( dueMs ( active ) - Date . now ());
const fill = document . getElementById ( "progress-fill" );
if ( fill ) fill . style . width = ` ${ progress ( active ) } %` ;
const total = document . getElementById ( "today-total" );
if ( total ) total . textContent = duration ( totals ( records , rangeWindow ( "day" )). totalMs );
}, 1000 );
async function initialize() {
records = await allRecords ();
render ();
if ( "serviceWorker" in navigator ) {
2026-11-08 14:49:29 -05:00
// On the first install, the new worker also takes control. That is not an update.
let hadController = Boolean ( navigator . serviceWorker . controller );
navigator . serviceWorker . addEventListener ( "controllerchange" , () => {
if ( ! hadController ) { hadController = true ; return ; }
updateReady = true ;
render ();
applyUpdateIfSafe ();
});
// An installed PWA can stay open for days without a navigation, so ask for updates.
setInterval ( checkForUpdate , 60 * 60 _000 );
2026-11-05 00:42:58 -05:00
try {
await navigator . serviceWorker . register ( "/sw.js" );
void navigator . serviceWorker . ready . then ( registration => { pushRegistration = registration ; render (); });
} catch ( error ) { console . warn ( "Service worker registration failed" , error ); }
}
try {
const response = await fetch ( "/api/me" , { cache : "no-store" });
me = await response . json () as Me ;
if ( me . authenticated ) localStorage . setItem ( "timelog-auth-seen" , "1" );
if ( me . authenticated ) sync ();
} catch {
me = { authenticated : Boolean ( localStorage . getItem ( "timelog-auth-seen" )), dev : false , githubConfigured : true , vapidPublicKey : null };
syncStatus = "offline" ;
}
await settleDue ();
render ();
}
void initialize (). catch ( error => { app . innerHTML = `<div class="login"><div class="notice error">Could not open the local log: ${ h ( error ) } </div></div>` ; });