+2
-2
api/yoten/actorprofile.go
+2
-2
api/yoten/actorprofile.go
···
5
5
// schema: app.yoten.actor.profile
6
6
7
7
import (
8
-
"github.com/bluesky-social/indigo/lex/util"
8
+
"github.com/bluesky-social/indigo/lex/util"
9
9
)
10
10
11
11
func init() {
12
-
util.RegisterType("app.yoten.actor.profile", &ActorProfile{})
12
+
util.RegisterType("app.yoten.actor.profile", &ActorProfile{})
13
13
} //
14
14
// RECORDTYPE: ActorProfile
15
15
type ActorProfile struct {
+347
api/yoten/cbor_gen.go
+347
api/yoten/cbor_gen.go
···
377
377
378
378
return nil
379
379
}
380
+
func (t *FeedSession) MarshalCBOR(w io.Writer) error {
381
+
if t == nil {
382
+
_, err := w.Write(cbg.CborNull)
383
+
return err
384
+
}
385
+
386
+
cw := cbg.NewCborWriter(w)
387
+
fieldCount := 7
388
+
389
+
if t.Description == nil {
390
+
fieldCount--
391
+
}
392
+
393
+
if t.Duration == nil {
394
+
fieldCount--
395
+
}
396
+
397
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
398
+
return err
399
+
}
400
+
401
+
// t.Date (string) (string)
402
+
if len("date") > 1000000 {
403
+
return xerrors.Errorf("Value in field \"date\" was too long")
404
+
}
405
+
406
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("date"))); err != nil {
407
+
return err
408
+
}
409
+
if _, err := cw.WriteString(string("date")); err != nil {
410
+
return err
411
+
}
412
+
413
+
if len(t.Date) > 1000000 {
414
+
return xerrors.Errorf("Value in field t.Date was too long")
415
+
}
416
+
417
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Date))); err != nil {
418
+
return err
419
+
}
420
+
if _, err := cw.WriteString(string(t.Date)); err != nil {
421
+
return err
422
+
}
423
+
424
+
// t.LexiconTypeID (string) (string)
425
+
if len("$type") > 1000000 {
426
+
return xerrors.Errorf("Value in field \"$type\" was too long")
427
+
}
428
+
429
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
430
+
return err
431
+
}
432
+
if _, err := cw.WriteString(string("$type")); err != nil {
433
+
return err
434
+
}
435
+
436
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("app.yoten.feed.session"))); err != nil {
437
+
return err
438
+
}
439
+
if _, err := cw.WriteString(string("app.yoten.feed.session")); err != nil {
440
+
return err
441
+
}
442
+
443
+
// t.Duration (string) (string)
444
+
if t.Duration != nil {
445
+
446
+
if len("duration") > 1000000 {
447
+
return xerrors.Errorf("Value in field \"duration\" was too long")
448
+
}
449
+
450
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("duration"))); err != nil {
451
+
return err
452
+
}
453
+
if _, err := cw.WriteString(string("duration")); err != nil {
454
+
return err
455
+
}
456
+
457
+
if t.Duration == nil {
458
+
if _, err := cw.Write(cbg.CborNull); err != nil {
459
+
return err
460
+
}
461
+
} else {
462
+
if len(*t.Duration) > 1000000 {
463
+
return xerrors.Errorf("Value in field t.Duration was too long")
464
+
}
465
+
466
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Duration))); err != nil {
467
+
return err
468
+
}
469
+
if _, err := cw.WriteString(string(*t.Duration)); err != nil {
470
+
return err
471
+
}
472
+
}
473
+
}
474
+
475
+
// t.CreatedAt (string) (string)
476
+
if len("createdAt") > 1000000 {
477
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
478
+
}
479
+
480
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
481
+
return err
482
+
}
483
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
484
+
return err
485
+
}
486
+
487
+
if len(t.CreatedAt) > 1000000 {
488
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
489
+
}
490
+
491
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
492
+
return err
493
+
}
494
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
495
+
return err
496
+
}
497
+
498
+
// t.Description (string) (string)
499
+
if t.Description != nil {
500
+
501
+
if len("description") > 1000000 {
502
+
return xerrors.Errorf("Value in field \"description\" was too long")
503
+
}
504
+
505
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("description"))); err != nil {
506
+
return err
507
+
}
508
+
if _, err := cw.WriteString(string("description")); err != nil {
509
+
return err
510
+
}
511
+
512
+
if t.Description == nil {
513
+
if _, err := cw.Write(cbg.CborNull); err != nil {
514
+
return err
515
+
}
516
+
} else {
517
+
if len(*t.Description) > 1000000 {
518
+
return xerrors.Errorf("Value in field t.Description was too long")
519
+
}
520
+
521
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Description))); err != nil {
522
+
return err
523
+
}
524
+
if _, err := cw.WriteString(string(*t.Description)); err != nil {
525
+
return err
526
+
}
527
+
}
528
+
}
529
+
530
+
// t.ActivityName (string) (string)
531
+
if len("activityName") > 1000000 {
532
+
return xerrors.Errorf("Value in field \"activityName\" was too long")
533
+
}
534
+
535
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("activityName"))); err != nil {
536
+
return err
537
+
}
538
+
if _, err := cw.WriteString(string("activityName")); err != nil {
539
+
return err
540
+
}
541
+
542
+
if len(t.ActivityName) > 1000000 {
543
+
return xerrors.Errorf("Value in field t.ActivityName was too long")
544
+
}
545
+
546
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.ActivityName))); err != nil {
547
+
return err
548
+
}
549
+
if _, err := cw.WriteString(string(t.ActivityName)); err != nil {
550
+
return err
551
+
}
552
+
553
+
// t.LanguageCode (string) (string)
554
+
if len("languageCode") > 1000000 {
555
+
return xerrors.Errorf("Value in field \"languageCode\" was too long")
556
+
}
557
+
558
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("languageCode"))); err != nil {
559
+
return err
560
+
}
561
+
if _, err := cw.WriteString(string("languageCode")); err != nil {
562
+
return err
563
+
}
564
+
565
+
if len(t.LanguageCode) > 1000000 {
566
+
return xerrors.Errorf("Value in field t.LanguageCode was too long")
567
+
}
568
+
569
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.LanguageCode))); err != nil {
570
+
return err
571
+
}
572
+
if _, err := cw.WriteString(string(t.LanguageCode)); err != nil {
573
+
return err
574
+
}
575
+
return nil
576
+
}
577
+
578
+
func (t *FeedSession) UnmarshalCBOR(r io.Reader) (err error) {
579
+
*t = FeedSession{}
580
+
581
+
cr := cbg.NewCborReader(r)
582
+
583
+
maj, extra, err := cr.ReadHeader()
584
+
if err != nil {
585
+
return err
586
+
}
587
+
defer func() {
588
+
if err == io.EOF {
589
+
err = io.ErrUnexpectedEOF
590
+
}
591
+
}()
592
+
593
+
if maj != cbg.MajMap {
594
+
return fmt.Errorf("cbor input should be of type map")
595
+
}
596
+
597
+
if extra > cbg.MaxLength {
598
+
return fmt.Errorf("FeedSession: map struct too large (%d)", extra)
599
+
}
600
+
601
+
n := extra
602
+
603
+
nameBuf := make([]byte, 12)
604
+
for i := uint64(0); i < n; i++ {
605
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
606
+
if err != nil {
607
+
return err
608
+
}
609
+
610
+
if !ok {
611
+
// Field doesn't exist on this type, so ignore it
612
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
613
+
return err
614
+
}
615
+
continue
616
+
}
617
+
618
+
switch string(nameBuf[:nameLen]) {
619
+
// t.Date (string) (string)
620
+
case "date":
621
+
622
+
{
623
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
624
+
if err != nil {
625
+
return err
626
+
}
627
+
628
+
t.Date = string(sval)
629
+
}
630
+
// t.LexiconTypeID (string) (string)
631
+
case "$type":
632
+
633
+
{
634
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
635
+
if err != nil {
636
+
return err
637
+
}
638
+
639
+
t.LexiconTypeID = string(sval)
640
+
}
641
+
// t.Duration (string) (string)
642
+
case "duration":
643
+
644
+
{
645
+
b, err := cr.ReadByte()
646
+
if err != nil {
647
+
return err
648
+
}
649
+
if b != cbg.CborNull[0] {
650
+
if err := cr.UnreadByte(); err != nil {
651
+
return err
652
+
}
653
+
654
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
655
+
if err != nil {
656
+
return err
657
+
}
658
+
659
+
t.Duration = (*string)(&sval)
660
+
}
661
+
}
662
+
// t.CreatedAt (string) (string)
663
+
case "createdAt":
664
+
665
+
{
666
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
667
+
if err != nil {
668
+
return err
669
+
}
670
+
671
+
t.CreatedAt = string(sval)
672
+
}
673
+
// t.Description (string) (string)
674
+
case "description":
675
+
676
+
{
677
+
b, err := cr.ReadByte()
678
+
if err != nil {
679
+
return err
680
+
}
681
+
if b != cbg.CborNull[0] {
682
+
if err := cr.UnreadByte(); err != nil {
683
+
return err
684
+
}
685
+
686
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
687
+
if err != nil {
688
+
return err
689
+
}
690
+
691
+
t.Description = (*string)(&sval)
692
+
}
693
+
}
694
+
// t.ActivityName (string) (string)
695
+
case "activityName":
696
+
697
+
{
698
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
699
+
if err != nil {
700
+
return err
701
+
}
702
+
703
+
t.ActivityName = string(sval)
704
+
}
705
+
// t.LanguageCode (string) (string)
706
+
case "languageCode":
707
+
708
+
{
709
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
710
+
if err != nil {
711
+
return err
712
+
}
713
+
714
+
t.LanguageCode = string(sval)
715
+
}
716
+
717
+
default:
718
+
// Field doesn't exist on this type, so ignore it
719
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
720
+
return err
721
+
}
722
+
}
723
+
}
724
+
725
+
return nil
726
+
}
+26
api/yoten/feedsession.go
+26
api/yoten/feedsession.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package yoten
4
+
5
+
// schema: app.yoten.feed.session
6
+
7
+
import (
8
+
"github.com/bluesky-social/indigo/lex/util"
9
+
)
10
+
11
+
func init() {
12
+
util.RegisterType("app.yoten.feed.session", &FeedSession{})
13
+
} //
14
+
// RECORDTYPE: FeedSession
15
+
type FeedSession struct {
16
+
LexiconTypeID string `json:"$type,const=app.yoten.feed.session" cborgen:"$type,const=app.yoten.feed.session"`
17
+
// activityName: A key used to lookup activity type details
18
+
ActivityName string `json:"activityName" cborgen:"activityName"`
19
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
20
+
Date string `json:"date" cborgen:"date"`
21
+
// description: Free-form description for the study session.
22
+
Description *string `json:"description,omitempty" cborgen:"description,omitempty"`
23
+
Duration *string `json:"duration,omitempty" cborgen:"duration,omitempty"`
24
+
// languageCode: An ISO 639-1 two-letter language code (e.g., 'en', 'es', 'ko').
25
+
LanguageCode string `json:"languageCode" cborgen:"languageCode"`
26
+
}
+2
cmd/gen.go
+2
cmd/gen.go
+19
generate-lexicons.sh
+19
generate-lexicons.sh
···
1
+
#! /bin/bash
2
+
3
+
go run github.com/bluesky-social/indigo/cmd/lexgen \
4
+
--package yoten \
5
+
--outdir api/yoten \
6
+
--build-file lexicon-build-config.json \
7
+
lexicons/
8
+
9
+
find ./api/yoten -type f -exec sed -i.bak 's/\tutil/\/\/\tutil/' {} +
10
+
find ./api/yoten -type f -exec sed -i.bak -E 's/^(.+)github(.+)//' {} +
11
+
go run ./cmd/gen.go
12
+
13
+
go run github.com/bluesky-social/indigo/cmd/lexgen \
14
+
--package yoten \
15
+
--outdir api/yoten \
16
+
--build-file lexicon-build-config.json \
17
+
lexicons/
18
+
19
+
rm -rf ./api/yoten/*.bak
+19
internal/web/db/db.go
+19
internal/web/db/db.go
···
92
92
foreign key (did) references profile(did) on delete cascade
93
93
);
94
94
95
+
create table if not exists study_session (
96
+
-- id
97
+
did text not null,
98
+
rkey text not null,
99
+
100
+
-- data
101
+
activity_name text not null,
102
+
description text,
103
+
duration integer not null,
104
+
language_code text not null,
105
+
date text not null,
106
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
107
+
108
+
-- constraints
109
+
check (length(language_code) = 2),
110
+
foreign key (did) references profile(did) on delete cascade
111
+
primary key (did, rkey)
112
+
);
113
+
95
114
create table if not exists _jetstream (
96
115
id integer primary key autoincrement,
97
116
last_time_us integer not null
+304
internal/web/db/study-session.go
+304
internal/web/db/study-session.go
···
1
+
package db
2
+
3
+
import (
4
+
"database/sql"
5
+
"fmt"
6
+
"log"
7
+
"time"
8
+
)
9
+
10
+
type StudySessionCategory int
11
+
12
+
const (
13
+
Reading StudySessionCategory = iota
14
+
Listening
15
+
Watching
16
+
Speaking
17
+
Writing
18
+
Pronunciation
19
+
Study
20
+
)
21
+
22
+
type StudySession struct {
23
+
Did string
24
+
Rkey string
25
+
// 64 characters
26
+
Description string
27
+
Activity Activity
28
+
Language Language
29
+
Duration time.Duration
30
+
Date time.Time
31
+
CreatedAt time.Time
32
+
}
33
+
34
+
type StudySessionFeedItem struct {
35
+
StudySession
36
+
ProfileDisplayName string
37
+
}
38
+
39
+
type Activity struct {
40
+
Name string
41
+
Description string
42
+
Categories []StudySessionCategory
43
+
}
44
+
45
+
var Activities = map[string]Activity{
46
+
"Vocab Study": {Name: "Vocab Study", Description: "Memorizing and reviewing new words and their meanings.", Categories: []StudySessionCategory{Study, Reading}},
47
+
"Grammar Study": {Name: "Grammar Study", Description: "Learning and practicing the structural rules of a language.", Categories: []StudySessionCategory{Study, Reading}},
48
+
"Sound Study": {Name: "Sound Study", Description: "Learning to hear and make the specific sounds of the language.", Categories: []StudySessionCategory{Study, Listening}},
49
+
"Alphabet Study": {Name: "Alphabet Study", Description: "Learning the written symbols and their corresponding sounds.", Categories: []StudySessionCategory{Study, Reading, Listening}},
50
+
"Character Study": {Name: "Character Study", Description: "Memorizing the individual characters or symbols of a writing system.", Categories: []StudySessionCategory{Study, Reading}},
51
+
52
+
"Freeflow Reading": {Name: "Freeflow Reading", Description: "Reading text continuously without stopping to look up unknown words.", Categories: []StudySessionCategory{Reading}},
53
+
"Interactive Reading": {Name: "Interactive Reading", Description: "Actively looking up and engaging with unknown vocabulary and grammar while reading.", Categories: []StudySessionCategory{Reading}},
54
+
"Freeflow Reading w/ Audio": {Name: "Freeflow Reading w/ Audio", Description: "Reading along with an accompanying audio track without interruption.", Categories: []StudySessionCategory{Reading, Listening, Watching}},
55
+
"Interactive Reading w/ Audio": {Name: "Interactive Reading w/ Audio", Description: "Actively engaging with a text and its accompanying audio, pausing to clarify and learn.", Categories: []StudySessionCategory{Reading, Listening, Watching}},
56
+
"Reading Aloud": {Name: "Reading Aloud", Description: "Vocalizing a text to practice pronunciation and fluency.", Categories: []StudySessionCategory{Reading, Pronunciation}},
57
+
"Sentence Mining": {Name: "Sentence Mining", Description: "Extracting sentences from native material to create flashcards.", Categories: []StudySessionCategory{Reading, Listening, Watching}},
58
+
"Interlinear Reading": {Name: "Interlinear Reading", Description: "Reading a text with a line-by-line translation.", Categories: []StudySessionCategory{Reading}},
59
+
"Video Games": {Name: "Video Games", Description: "Learning the language by playing video games.", Categories: []StudySessionCategory{Reading, Listening}},
60
+
61
+
"Freeflow Listening": {Name: "Freeflow Listening", Description: "Listening to audio content without pausing or looking up unknown words.", Categories: []StudySessionCategory{Listening, Watching}},
62
+
"Interactive Listening": {Name: "Interactive Listening", Description: "Actively pausing and re-listening to audio to understand and learn new language elements.", Categories: []StudySessionCategory{Listening, Watching}},
63
+
"Low-Attention Listening": {Name: "Low-Attention Listening", Description: "Playing the language in the background while you do chores or work.", Categories: []StudySessionCategory{Listening}},
64
+
"Crosstalking": {Name: "Crosstalking", Description: "A conversational exchange where each person speaks a different language.", Categories: []StudySessionCategory{Listening, Speaking}},
65
+
"Chorusing": {Name: "Chorusing", Description: "Speaking words or sentences at the exact same time as a recording.", Categories: []StudySessionCategory{Listening, Speaking, Pronunciation, Study}},
66
+
"Shadowing": {Name: "Shadowing", Description: "Copying a native speaker by repeating what they say almost instantly.", Categories: []StudySessionCategory{Listening, Speaking, Pronunciation, Study}},
67
+
"Listen Looping": {Name: "Listen Looping", Description: "Repeatedly listening to a short segment of audio to improve comprehension.", Categories: []StudySessionCategory{Listening, Watching, Study}},
68
+
"Ear Training": {Name: "Ear Training", Description: "Focusing on distinguishing between similar sounds in the target language.", Categories: []StudySessionCategory{Listening, Study}},
69
+
"Transcription": {Name: "Transcription", Description: "Writing down what you hear from an audio or video recording.", Categories: []StudySessionCategory{Listening, Study}},
70
+
71
+
"Speaking with Partner": {Name: "Speaking with Partner", Description: "Practicing conversational skills through live interaction with another person.", Categories: []StudySessionCategory{Speaking}},
72
+
"Speaking Alone": {Name: "Speaking Alone", Description: "Practicing speaking through conversations with yourself.", Categories: []StudySessionCategory{Speaking}},
73
+
"Speaking Analysis": {Name: "Speaking Analysis", Description: "Recording and reviewing your own speech to identify areas for improvement.", Categories: []StudySessionCategory{Speaking, Study}},
74
+
"Singing": {Name: "Singing", Description: "Using music and lyrics to practice pronunciation, rhythm, and intonation.", Categories: []StudySessionCategory{Speaking, Pronunciation}},
75
+
"Phrase Memorization": {Name: "Phrase Memorization", Description: "ctively learning and recalling common phrases and expressions.", Categories: []StudySessionCategory{Speaking, Study}},
76
+
77
+
"Assisted Writing": {Name: "Assisted Writing", Description: "Composing text with the help of translation tools, dictionaries, or grammar checkers.", Categories: []StudySessionCategory{Writing}},
78
+
"Unassisted Writing": {Name: "Unassisted Writing", Description: "Writing freely without any external aids to test your independent skills.", Categories: []StudySessionCategory{Writing}},
79
+
"Writing Analysis": {Name: "Writing Analysis", Description: "Reviewing and correcting your written work, often with feedback from others.", Categories: []StudySessionCategory{Writing, Study}},
80
+
"Copywork": {Name: "Copywork", Description: "Manually copying well-structured native texts to internalize grammar and style.", Categories: []StudySessionCategory{Writing, Study}},
81
+
"Typing Practice": {Name: "Typing Practice", Description: "Improving your speed and accuracy when typing in the target language's script.", Categories: []StudySessionCategory{Writing, Study}},
82
+
"Handwriting Practice": {Name: "Handwriting Practice", Description: "Practicing the physical act of writing the characters or script of a language.", Categories: []StudySessionCategory{Writing, Study}},
83
+
84
+
"Language App": {Name: "Language App", Description: "Using a mobile or web application for structured language lessons and exercises.", Categories: []StudySessionCategory{Study}},
85
+
"Language Class": {Name: "Language Class", Description: "Attending a formal course with an instructor for guided learning.", Categories: []StudySessionCategory{Study}},
86
+
"Test Prep": {Name: "Test Prep", Description: "Specifically studying and practicing for a language proficiency examination.", Categories: []StudySessionCategory{Study}},
87
+
"Flashcard Creation": {Name: "Flashcard Creation", Description: "Making your own digital or physical flashcards for active recall practice.", Categories: []StudySessionCategory{Study}},
88
+
89
+
"Other": {Name: "Other", Description: "Any miscellaneous language learning activity that doesn't fit into the other categories.", Categories: []StudySessionCategory{}},
90
+
}
91
+
92
+
func UpsertStudySession(tx *sql.Tx, session *StudySession, rkey string) error {
93
+
defer tx.Rollback()
94
+
95
+
_, err := tx.Exec(
96
+
`insert or replace into study_session (
97
+
did,
98
+
rkey,
99
+
activity_name,
100
+
description,
101
+
duration,
102
+
language_code,
103
+
date
104
+
)
105
+
values (?, ?, ?, ?, ?, ?, ?)`,
106
+
session.Did,
107
+
rkey,
108
+
session.Activity.Name,
109
+
session.Description,
110
+
session.Duration.Seconds(),
111
+
session.Language.Code,
112
+
session.Date.Format(time.RFC3339),
113
+
)
114
+
if err != nil {
115
+
log.Println("failed to insert or update study session", err)
116
+
return err
117
+
}
118
+
119
+
return tx.Commit()
120
+
}
121
+
122
+
func GetStudySessionFeed(e Execer, limit, offset int) ([]*StudySessionFeedItem, error) {
123
+
query := `
124
+
select
125
+
ss.did, ss.rkey, ss.activity_name, ss.description, ss.duration, ss.language_code, ss.date, ss.created,
126
+
p.display_name
127
+
from study_session as ss
128
+
join profile as p on ss.did = p.did
129
+
order by ss.date desc
130
+
limit ? offset ?`
131
+
132
+
rows, err := e.Query(query, limit, offset)
133
+
if err != nil {
134
+
return nil, fmt.Errorf("failed to query for activity logs: %w", err)
135
+
}
136
+
defer rows.Close()
137
+
138
+
var feedItems []*StudySessionFeedItem
139
+
140
+
for rows.Next() {
141
+
var language Language
142
+
var activity Activity
143
+
var dateStr string
144
+
var duration int64
145
+
var session StudySession
146
+
var item StudySessionFeedItem
147
+
var createdAtStr string
148
+
149
+
err := rows.Scan(
150
+
&session.Did, &session.Rkey, &activity.Name,
151
+
&session.Description, &duration, &language.Code, &dateStr,
152
+
&createdAtStr, &item.ProfileDisplayName,
153
+
)
154
+
if err != nil {
155
+
return nil, fmt.Errorf("failed to scan activity log row: %w", err)
156
+
}
157
+
158
+
date, err := time.Parse(time.RFC3339, dateStr)
159
+
if err != nil {
160
+
return nil, fmt.Errorf("failed to parse date string '%s': %w", dateStr, err)
161
+
}
162
+
session.Date = date
163
+
session.Duration = time.Duration(duration) * time.Second
164
+
165
+
createdAt, err := time.Parse(time.RFC3339, createdAtStr)
166
+
if err != nil {
167
+
return nil, fmt.Errorf("failed to parse createdAt string '%s': %w", createdAtStr, err)
168
+
}
169
+
session.CreatedAt = createdAt
170
+
171
+
language, ok := Languages[language.Code]
172
+
if !ok {
173
+
return nil, fmt.Errorf("failed to find language '%s'", language.Code)
174
+
}
175
+
session.Language = language
176
+
177
+
activity, ok = Activities[activity.Name]
178
+
if !ok {
179
+
return nil, fmt.Errorf("failed to find activity '%s'", activity.Name)
180
+
}
181
+
session.Activity = activity
182
+
183
+
item.StudySession = session
184
+
185
+
feedItems = append(feedItems, &item)
186
+
}
187
+
188
+
if err = rows.Err(); err != nil {
189
+
return nil, fmt.Errorf("failed to iterate activity log rows: %w", err)
190
+
}
191
+
192
+
return feedItems, nil
193
+
}
194
+
195
+
func GetStudySessionLogs(e Execer, did string, limit, offset int) ([]*StudySession, error) {
196
+
query := `
197
+
rkey, activity_name, description, duration, date
198
+
from study_session
199
+
where profile_did = ?
200
+
order by date desc
201
+
limit ? offset ?`
202
+
203
+
rows, err := e.Query(query, did, limit, offset)
204
+
if err != nil {
205
+
return nil, fmt.Errorf("failed to query for activity logs: %w", err)
206
+
}
207
+
defer rows.Close()
208
+
209
+
var activityLogs []*StudySession
210
+
211
+
for rows.Next() {
212
+
var language Language
213
+
var activity Activity
214
+
var dateStr string
215
+
var duration int64
216
+
var session StudySession
217
+
218
+
err := rows.Scan(&session.Rkey, &activity.Name, &session.Description, &duration, &language.Code, &dateStr)
219
+
if err != nil {
220
+
return nil, fmt.Errorf("failed to scan activity log row: %w", err)
221
+
}
222
+
223
+
parsedTime, err := time.Parse(time.RFC3339, dateStr)
224
+
if err != nil {
225
+
return nil, fmt.Errorf("failed to parse date string '%s': %w", dateStr, err)
226
+
}
227
+
session.Date = parsedTime
228
+
session.Duration = time.Duration(duration) * time.Second
229
+
230
+
language, ok := Languages[language.Code]
231
+
if !ok {
232
+
return nil, fmt.Errorf("failed to find language '%s'", language.Code)
233
+
}
234
+
session.Language = language
235
+
236
+
activity, ok = Activities[activity.Name]
237
+
if !ok {
238
+
return nil, fmt.Errorf("failed to find activity '%s'", activity.Name)
239
+
}
240
+
session.Activity = activity
241
+
242
+
activityLogs = append(activityLogs, &session)
243
+
}
244
+
245
+
if err = rows.Err(); err != nil {
246
+
return nil, fmt.Errorf("failed to iterate activity log rows: %w", err)
247
+
}
248
+
249
+
return activityLogs, nil
250
+
}
251
+
252
+
func DeleteStudySessionByRkey(e Execer, did string, rkey string) error {
253
+
_, err := e.Exec(`delete from study_session where did = ? and rkey = ?`, did, rkey)
254
+
return err
255
+
}
256
+
257
+
func GetStudySessionByRkey(e Execer, did string, rkey string) (*StudySession, error) {
258
+
var language Language
259
+
var activity Activity
260
+
var dateStr string
261
+
var createdStr string
262
+
var duration int64
263
+
264
+
var session StudySession
265
+
session.Did = did
266
+
session.Rkey = rkey
267
+
268
+
err := e.QueryRow(
269
+
`select activity_name, description, duration, language_code, date, created from study_session where did = ? and rkey = ?`,
270
+
did, rkey,
271
+
).Scan(&activity.Name, &session.Description, &duration, &language.Code, &dateStr, &createdStr)
272
+
if err != nil {
273
+
if err == sql.ErrNoRows {
274
+
return nil, fmt.Errorf("study session does not exist")
275
+
}
276
+
return nil, err
277
+
}
278
+
279
+
date, err := time.Parse(time.RFC3339, dateStr)
280
+
if err != nil {
281
+
return nil, fmt.Errorf("failed to parse date string '%s': %w", dateStr, err)
282
+
}
283
+
session.Date = date
284
+
session.Duration = time.Duration(duration) * time.Second
285
+
created, err := time.Parse(time.RFC3339, createdStr)
286
+
if err != nil {
287
+
return nil, fmt.Errorf("failed to parse created string '%s': %w", dateStr, err)
288
+
}
289
+
session.CreatedAt = created
290
+
291
+
language, ok := Languages[language.Code]
292
+
if !ok {
293
+
return nil, fmt.Errorf("failed to find language '%s'", language.Code)
294
+
}
295
+
session.Language = language
296
+
297
+
activity, ok = Activities[activity.Name]
298
+
if !ok {
299
+
return nil, fmt.Errorf("failed to find activity '%s'", activity.Name)
300
+
}
301
+
session.Activity = activity
302
+
303
+
return &session, nil
304
+
}
+82
internal/web/ingester/ingester.go
+82
internal/web/ingester/ingester.go
···
5
5
"encoding/json"
6
6
"fmt"
7
7
"log"
8
+
"strconv"
9
+
"time"
8
10
9
11
"github.com/bluesky-social/jetstream/pkg/models"
10
12
"yoten.app/api/yoten"
···
31
33
switch e.Commit.Collection {
32
34
case "app.yoten.actor.profile":
33
35
ingestProfile(&d, e)
36
+
case "app.yoten.feed.session":
37
+
ingestStudySession(&d, e)
34
38
}
35
39
36
40
return err
···
108
112
109
113
return nil
110
114
}
115
+
116
+
func ingestStudySession(d *db.DbWrapper, e *models.Event) error {
117
+
did := e.Did
118
+
var err error
119
+
120
+
switch e.Commit.Operation {
121
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
122
+
raw := json.RawMessage(e.Commit.Record)
123
+
record := yoten.FeedSession{}
124
+
err = json.Unmarshal(raw, &record)
125
+
if err != nil {
126
+
log.Printf("invalid record: %s", err)
127
+
return err
128
+
}
129
+
130
+
description := ""
131
+
if record.Description != nil {
132
+
description = *record.Description
133
+
}
134
+
135
+
date, err := time.Parse(time.RFC3339, record.Date)
136
+
if err != nil {
137
+
log.Printf("invalid record: %s", err)
138
+
return err
139
+
}
140
+
141
+
languageCode := record.LanguageCode
142
+
language, ok := db.Languages[db.LanguageCode(languageCode)]
143
+
if !ok {
144
+
return fmt.Errorf("failed to find language '%s'", languageCode)
145
+
}
146
+
147
+
activityName := record.ActivityName
148
+
activity, ok := db.Activities[activityName]
149
+
if !ok {
150
+
return fmt.Errorf("failed to find activity '%s'", activity.Name)
151
+
}
152
+
153
+
var duration int64 = 0
154
+
if record.Duration != nil {
155
+
duration, err = strconv.ParseInt(*record.Duration, 10, 64)
156
+
if err != nil {
157
+
log.Printf("invalid record: %s", err)
158
+
return err
159
+
}
160
+
}
161
+
162
+
session := db.StudySession{
163
+
Did: did,
164
+
Rkey: e.Commit.RKey,
165
+
Description: description,
166
+
Activity: activity,
167
+
Language: language,
168
+
Duration: time.Duration(duration) * time.Second,
169
+
Date: date,
170
+
}
171
+
172
+
ddb, ok := d.Execer.(*db.DB)
173
+
if !ok {
174
+
return fmt.Errorf("failed to index study session record, invalid db cast")
175
+
}
176
+
177
+
tx, err := ddb.Begin()
178
+
if err != nil {
179
+
return fmt.Errorf("failed to start transaction")
180
+
}
181
+
182
+
log.Println("Upserting study session")
183
+
err = db.UpsertStudySession(tx, &session, e.Commit.RKey)
184
+
case models.CommitOperationDelete:
185
+
err = db.DeleteStudySessionByRkey(d, did, e.Commit.RKey)
186
+
}
187
+
if err != nil {
188
+
return fmt.Errorf("failed to %s study session record: %w", e.Commit.Operation, err)
189
+
}
190
+
191
+
return nil
192
+
}
+15
-5
internal/web/pages/index.templ
+15
-5
internal/web/pages/index.templ
···
1
1
package pages
2
2
3
-
import "yoten.app/internal/web/pages/templates"
3
+
import (
4
+
"yoten.app/internal/web/db"
5
+
"yoten.app/internal/web/pages/partials"
6
+
"yoten.app/internal/web/pages/templates"
7
+
)
4
8
5
9
templ Index(params IndexParams) {
6
-
{{ user := params.User }}
7
10
@templates.BaseLayout("home") {
8
11
<span>
9
12
Hello,
10
-
if user != nil {
11
-
{ user.Handle }
13
+
if params.User != nil {
14
+
{ params.User.Handle }
12
15
} else {
13
16
world
14
17
}
15
18
</span>
16
-
if user != nil {
19
+
if params.User != nil {
17
20
<button type="button" hx-post="/logout" hx-swap="none">logout</button>
18
21
} else {
19
22
<a href="/login">login</a>
23
+
}
24
+
for _, feedItem := range params.StudySessionFeed {
25
+
if params.User != nil {
26
+
@partials.StudySession(partials.StudySessionParams{Item: feedItem, Did: db.ToPtr(params.User.Did)})
27
+
} else {
28
+
@partials.StudySession(partials.StudySessionParams{Item: feedItem, Did: nil})
29
+
}
20
30
}
21
31
}
22
32
}
+65
internal/web/pages/new-study-session.templ
+65
internal/web/pages/new-study-session.templ
···
1
+
package pages
2
+
3
+
import (
4
+
"yoten.app/internal/web/db"
5
+
"yoten.app/internal/web/pages/templates"
6
+
)
7
+
8
+
templ NewStudySession(params NewStudySessionParams) {
9
+
@templates.BaseLayout("home") {
10
+
<form hx-post="/session/new" hx-swap="none" hx-disabled-elt="#save-button,#cancel-button">
11
+
<div>
12
+
<label for="description">Description</label>
13
+
<textarea maxlength="256" type="text" name="description" rows="3" placeholder="write a description"></textarea>
14
+
</div>
15
+
<div>
16
+
<label for="date">Date</label>
17
+
<input type="date" name="date"/>
18
+
</div>
19
+
<div>
20
+
<label for="duration">Duration (HH:MM:SS):</label>
21
+
<input
22
+
type="text"
23
+
id="duration"
24
+
name="duration"
25
+
pattern="[0-9]{2}:[0-5][0-9]:[0-5][0-9]"
26
+
placeholder="hh:mm:ss"
27
+
/>
28
+
</div>
29
+
<div>
30
+
<select name="language_code">
31
+
<option disabled selected>Select a language...</option>
32
+
for c, l := range db.Languages {
33
+
<option value={ string(c) }>
34
+
{ l.Name }
35
+
if l.NativeName != nil {
36
+
({ *l.NativeName })
37
+
}
38
+
</option>
39
+
}
40
+
</select>
41
+
</div>
42
+
<div>
43
+
<select name="activity_name">
44
+
<option disabled selected>Select an activity...</option>
45
+
for c, a := range db.Activities {
46
+
<option value={ string(c) }>
47
+
{ a.Name }
48
+
</option>
49
+
}
50
+
</select>
51
+
</div>
52
+
<div>
53
+
<button id="save-button" type="submit">
54
+
save
55
+
</button>
56
+
<a href="/">
57
+
<button id="cancel-button" type="button">
58
+
cancel
59
+
</button>
60
+
</a>
61
+
</div>
62
+
</form>
63
+
<p id="create-study-session-msg"></p>
64
+
}
65
+
}
+9
-1
internal/web/pages/pages.go
+9
-1
internal/web/pages/pages.go
···
9
9
10
10
type IndexParams struct {
11
11
// The current logged in user.
12
-
User *oauth.User
12
+
User *oauth.User
13
+
StudySessionFeed []*db.StudySessionFeedItem
13
14
}
14
15
15
16
func IndexComponent(params IndexParams) templ.Component {
···
37
38
func NotFoundComponent() templ.Component {
38
39
return NotFound()
39
40
}
41
+
42
+
type NewStudySessionParams struct {
43
+
}
44
+
45
+
func NewStudySessionComponent(params NewStudySessionParams) templ.Component {
46
+
return NewStudySession(params)
47
+
}
+84
internal/web/pages/partials/edit-study-session.templ
+84
internal/web/pages/partials/edit-study-session.templ
···
1
+
package partials
2
+
3
+
import (
4
+
"fmt"
5
+
6
+
"yoten.app/internal/web"
7
+
"yoten.app/internal/web/db"
8
+
)
9
+
10
+
templ EditStudySession(params EditStudySessionParams) {
11
+
<form
12
+
hx-post={ fmt.Sprintf("/session/edit/%s", params.StudySession.Rkey) }
13
+
hx-swap="none"
14
+
hx-disabled-elt="#delete-button,#save-button,#cancel-button"
15
+
>
16
+
<div>
17
+
<label for="description">Description</label>
18
+
<textarea maxlength="256" type="text" name="description" rows="3" placeholder="write a description">
19
+
{ params.StudySession.Description }
20
+
</textarea>
21
+
</div>
22
+
<div>
23
+
<label for="date">Date</label>
24
+
<input type="date" name="date" value={ params.StudySession.Date.Format("2006-01-02") }/>
25
+
</div>
26
+
<div>
27
+
<label for="duration">Duration (HH:MM:SS):</label>
28
+
<input
29
+
type="text"
30
+
id="duration"
31
+
name="duration"
32
+
pattern="[0-9]{2}:[0-5][0-9]:[0-5][0-9]"
33
+
placeholder="hh:mm:ss"
34
+
value={ web.FormatDurationHHMMSS(params.StudySession.Duration) }
35
+
/>
36
+
</div>
37
+
<div>
38
+
<select name="language_code">
39
+
<option disabled selected>Select a language...</option>
40
+
for c, l := range db.Languages {
41
+
<option value={ string(c) } if c==params.StudySession.Language.Code {
42
+
selected
43
+
}>
44
+
{ l.Name }
45
+
if l.NativeName != nil {
46
+
({ *l.NativeName })
47
+
}
48
+
</option>
49
+
}
50
+
</select>
51
+
</div>
52
+
<div>
53
+
<select name="activity_name">
54
+
<option disabled selected>Select an activity...</option>
55
+
for k, a := range db.Activities {
56
+
<option value={ string(k) } if k==params.StudySession.Activity.Name {
57
+
selected
58
+
}>
59
+
{ a.Name }
60
+
</option>
61
+
}
62
+
</select>
63
+
</div>
64
+
<div>
65
+
<button
66
+
id="delete-button"
67
+
hx-disabled-elt="#delete-button,#save-button,#cancel-button"
68
+
hx-delete="/session"
69
+
hx-vals={ fmt.Sprintf("{\"rkey\": \"%s\"}", params.StudySession.Rkey) }
70
+
>
71
+
delete
72
+
</button>
73
+
<button id="save-button" type="submit">
74
+
save
75
+
</button>
76
+
<a href="/">
77
+
<button id="cancel-button" type="button">
78
+
cancel
79
+
</button>
80
+
</a>
81
+
</div>
82
+
</form>
83
+
<p id="update-study-session-msg"></p>
84
+
}
+18
internal/web/pages/partials/partials.go
+18
internal/web/pages/partials/partials.go
···
24
24
func SelectedLanguagesPartial(params SelectedLanguagesParams) templ.Component {
25
25
return SelectedLanguages(params)
26
26
}
27
+
28
+
type StudySessionParams struct {
29
+
// The Did of the logged in user.
30
+
Did *string
31
+
Item *db.StudySessionFeedItem
32
+
}
33
+
34
+
func StudySessionPartial(params StudySessionParams) templ.Component {
35
+
return StudySession(params)
36
+
}
37
+
38
+
type EditStudySessionParams struct {
39
+
StudySession *db.StudySession
40
+
}
41
+
42
+
func EditStudySessionPartial(params EditStudySessionParams) templ.Component {
43
+
return EditStudySession(params)
44
+
}
+41
internal/web/pages/partials/study-session.templ
+41
internal/web/pages/partials/study-session.templ
···
1
+
package partials
2
+
3
+
import "fmt"
4
+
5
+
templ StudySession(params StudySessionParams) {
6
+
{{ divId := fmt.Sprintf("study-session-%s", params.Item.Rkey) }}
7
+
<div id={ divId }>
8
+
<div>
9
+
<p>{ params.Item.ProfileDisplayName }</p>
10
+
<p>{ params.Item.Language.Name }</p>
11
+
</div>
12
+
<p>
13
+
<bold>{ params.Item.StudySession.Description }</bold>
14
+
</p>
15
+
<small>{ params.Item.StudySession.Activity.Name }</small>
16
+
<p>{ params.Item.StudySession.Duration.String() }</p>
17
+
<p>{ params.Item.StudySession.Date.Format("2006-01-02") }</p>
18
+
if params.Did != nil && params.Item.Did == *params.Did {
19
+
<div>
20
+
<button
21
+
id="remove-session-button"
22
+
hx-disabled-elt="#edit-session-button,#remove-session-button,#save-button,#cancel-button"
23
+
hx-delete="/session"
24
+
hx-vals={ fmt.Sprintf("{\"rkey\": \"%s\"}", params.Item.Rkey) }
25
+
>
26
+
delete
27
+
</button>
28
+
<button
29
+
id="edit-session-button"
30
+
hx-disabled-elt="#edit-session-button,#remove-session-button,#save-button,#cancel-button"
31
+
hx-swap="innerHTML"
32
+
hx-target={ fmt.Sprintf("#%s", divId) }
33
+
hx-get={ fmt.Sprintf("/session/edit/%s",
34
+
params.Item.Rkey) }
35
+
>
36
+
edit
37
+
</button>
38
+
</div>
39
+
}
40
+
</div>
41
+
}
+7
-2
internal/web/posthog/posthog.go
+7
-2
internal/web/posthog/posthog.go
···
1
1
package posthog
2
2
3
3
const (
4
-
UserProfileEditedEvent string = "user-profile-edited"
5
-
UserSignedInEvent string = "user-signed-in"
4
+
UserProfileEditedEvent string = "user-profile-edited"
5
+
UserSignedInEvent string = "user-signed-in"
6
+
6
7
ProfileRecordCreatedEvent string = "profile-record-created"
8
+
9
+
StudySessionRecordCreatedEvent string = "study-session-record-created"
10
+
StudySessionRecordDeletedEvent string = "study-session-record-deleted"
11
+
StudySessionRecordEditedEvent string = "study-session-record-edited"
7
12
)
+9
internal/web/state/router.go
+9
internal/web/state/router.go
···
45
45
r.Post("/remove-language", s.HandleRemoveLanguage)
46
46
})
47
47
48
+
r.Route("/session", func(r chi.Router) {
49
+
r.Use(middleware.AuthMiddleware(s.oauth))
50
+
r.Post("/edit/{rkey}", s.HandleEditStudySession)
51
+
r.Get("/edit/{rkey}", s.HandleEditStudySession)
52
+
r.Get("/new", s.HandleNewStudySession)
53
+
r.Post("/new", s.HandleNewStudySession)
54
+
r.Delete("/", s.HandleDeleteStudySession)
55
+
})
56
+
48
57
return r
49
58
}
50
59
+13
-1
internal/web/state/state.go
+13
-1
internal/web/state/state.go
···
3
3
import (
4
4
"context"
5
5
"fmt"
6
+
"log"
6
7
"log/slog"
7
8
"net/http"
8
9
···
46
47
"appview",
47
48
[]string{
48
49
"app.yoten.actor.profile",
50
+
"app.yoten.feed.session",
49
51
},
50
52
nil,
51
53
slog.Default(),
···
73
75
74
76
func (s *State) HandleIndex(w http.ResponseWriter, r *http.Request) {
75
77
user := s.oauth.GetUser(r)
76
-
pages.IndexComponent(pages.IndexParams{User: user}).Render(r.Context(), w)
78
+
79
+
feeds, err := db.GetStudySessionFeed(s.db, 10, 0)
80
+
if err != nil {
81
+
log.Println(err)
82
+
return
83
+
}
84
+
85
+
pages.IndexComponent(pages.IndexParams{
86
+
User: user,
87
+
StudySessionFeed: feeds,
88
+
}).Render(r.Context(), w)
77
89
}
+335
internal/web/state/study-session.go
+335
internal/web/state/study-session.go
···
1
+
package state
2
+
3
+
import (
4
+
"log"
5
+
"net/http"
6
+
"strconv"
7
+
"time"
8
+
9
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
10
+
lexutil "github.com/bluesky-social/indigo/lex/util"
11
+
"github.com/go-chi/chi/v5"
12
+
"github.com/posthog/posthog-go"
13
+
14
+
"yoten.app/api/yoten"
15
+
"yoten.app/internal/web"
16
+
"yoten.app/internal/web/db"
17
+
"yoten.app/internal/web/htmx"
18
+
"yoten.app/internal/web/pages"
19
+
"yoten.app/internal/web/pages/partials"
20
+
ph "yoten.app/internal/web/posthog"
21
+
)
22
+
23
+
func (s *State) HandleEditStudySession(w http.ResponseWriter, r *http.Request) {
24
+
user := s.oauth.GetUser(r)
25
+
profile, err := db.GetProfile(s.db, user.Did)
26
+
if err != nil || profile == nil {
27
+
log.Printf("failed to find %s in db: %s", user.Did, err)
28
+
pages.NotFound().Render(r.Context(), w)
29
+
return
30
+
}
31
+
32
+
rkey := chi.URLParam(r, "rkey")
33
+
session, err := db.GetStudySessionByRkey(s.db, user.Did, rkey)
34
+
if err != nil {
35
+
log.Println("failed to get study session from db", err)
36
+
htmx.HxOobUpdate(w, "update-study-session-msg", "Failed to update study session, try again later.")
37
+
return
38
+
}
39
+
40
+
if user.Did != session.Did {
41
+
log.Println("failed to update study session: user does not own record")
42
+
htmx.HxOobUpdate(w, "update-study-session-msg", "Failed to update study session, try again later.")
43
+
return
44
+
}
45
+
46
+
switch r.Method {
47
+
case http.MethodGet:
48
+
partials.EditStudySessionPartial(partials.EditStudySessionParams{StudySession: session}).Render(r.Context(), w)
49
+
case http.MethodPost:
50
+
description := r.FormValue("description")
51
+
activityName := r.FormValue("activity_name")
52
+
dateStr := r.FormValue("date")
53
+
date, err := time.Parse("2006-01-02", dateStr)
54
+
if err != nil {
55
+
log.Println("failed to parse study session date", err)
56
+
htmx.HxOobUpdate(w, "update-study-session-msg", "Failed to update study session, try again later.")
57
+
return
58
+
}
59
+
60
+
durationStr := r.FormValue("duration")
61
+
duration, err := web.ParseDurationHHMMSS(durationStr)
62
+
if err != nil {
63
+
log.Println("failed to parse study session duration", err)
64
+
htmx.HxOobUpdate(w, "update-study-session-msg", "Failed to update study session, try again later.")
65
+
return
66
+
}
67
+
68
+
languageCode := r.FormValue("language_code")
69
+
70
+
client, err := s.oauth.AuthorizedClient(r)
71
+
if err != nil {
72
+
log.Println("failed to get authorized client", err)
73
+
htmx.HxOobUpdate(w, "update-study-session-msg", "Failed to update study session, try again later.")
74
+
return
75
+
}
76
+
77
+
ex, _ := client.RepoGetRecord(r.Context(), "", "app.yoten.feed.session", user.Did, session.Rkey)
78
+
var cid *string
79
+
if ex != nil {
80
+
cid = ex.Cid
81
+
}
82
+
83
+
language, ok := db.Languages[db.LanguageCode(languageCode)]
84
+
if !ok {
85
+
log.Printf("failed to find language '%s'", language.Code)
86
+
htmx.HxOobUpdate(w, "update-study-session-msg", "Failed to update study session, try again later.")
87
+
log.Println(err)
88
+
return
89
+
}
90
+
91
+
activity, ok := db.Activities[activityName]
92
+
if !ok {
93
+
log.Println("failed to find activity '%s'", activity.Name)
94
+
htmx.HxOobUpdate(w, "update-study-session-msg", "Failed to update study session, try again later.")
95
+
}
96
+
97
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
98
+
Collection: "app.yoten.feed.session",
99
+
Repo: user.Did,
100
+
Rkey: session.Rkey,
101
+
Record: &lexutil.LexiconTypeDecoder{
102
+
Val: &yoten.FeedSession{
103
+
Description: &description,
104
+
ActivityName: activityName,
105
+
Date: date.Format(time.RFC3339),
106
+
Duration: db.ToPtr(strconv.FormatInt(int64(duration.Seconds()), 10)),
107
+
LanguageCode: languageCode,
108
+
CreatedAt: session.CreatedAt.Format(time.RFC3339),
109
+
}},
110
+
SwapRecord: cid,
111
+
})
112
+
if err != nil {
113
+
log.Println("failed to update study session record", err)
114
+
htmx.HxOobUpdate(w, "update-study-session-msg", "Failed to update PDS, try again later.")
115
+
return
116
+
}
117
+
118
+
updatedSession := db.StudySession{
119
+
Did: session.Did,
120
+
Rkey: session.Rkey,
121
+
Description: description,
122
+
Date: date,
123
+
Duration: duration,
124
+
Activity: activity,
125
+
Language: language,
126
+
}
127
+
128
+
tx, err := s.db.BeginTx(r.Context(), nil)
129
+
if err != nil {
130
+
log.Println("failed to start tx")
131
+
htmx.HxOobUpdate(w, "update-study-session-msg", "Failed to update study session, try again later.")
132
+
return
133
+
}
134
+
135
+
err = db.UpsertStudySession(tx, &updatedSession, session.Rkey)
136
+
if err != nil {
137
+
// Hopeful ingester updates DB for us.
138
+
log.Println("failed to update study session", err)
139
+
}
140
+
141
+
log.Println("edited study session")
142
+
if !s.config.Core.Dev {
143
+
err = s.posthog.Enqueue(posthog.Capture{
144
+
DistinctId: user.Did,
145
+
Event: ph.StudySessionRecordEditedEvent,
146
+
Properties: posthog.NewProperties().Set("rkey", session.Rkey),
147
+
})
148
+
if err != nil {
149
+
log.Println("failed to enqueue posthog event:", err)
150
+
}
151
+
}
152
+
153
+
htmx.HxRedirect(w, "/")
154
+
}
155
+
}
156
+
157
+
func (s *State) HandleNewStudySession(w http.ResponseWriter, r *http.Request) {
158
+
user := s.oauth.GetUser(r)
159
+
client, err := s.oauth.AuthorizedClient(r)
160
+
if err != nil {
161
+
log.Println("failed to get authorized client", err)
162
+
htmx.HxOobUpdate(w, "create-study-session-msg", "Failed to create study session, try again later.")
163
+
return
164
+
}
165
+
166
+
switch r.Method {
167
+
case http.MethodGet:
168
+
pages.NewStudySessionComponent(pages.NewStudySessionParams{}).Render(r.Context(), w)
169
+
case http.MethodPost:
170
+
err := r.ParseForm()
171
+
if err != nil {
172
+
log.Println("invalid study session create form", err)
173
+
htmx.HxOobUpdate(w, "create-study-session-msg", "Failed to create study session, try again later.")
174
+
return
175
+
}
176
+
177
+
description := r.FormValue("description")
178
+
dateStr := r.FormValue("date")
179
+
date, err := time.Parse("2006-01-02", dateStr)
180
+
if err != nil {
181
+
log.Println("failed to parse study session date", err)
182
+
htmx.HxOobUpdate(w, "create-study-session-msg", "Failed to create study session, try again later.")
183
+
return
184
+
}
185
+
186
+
durationStr := r.FormValue("duration")
187
+
duration, err := web.ParseDurationHHMMSS(durationStr)
188
+
if err != nil {
189
+
log.Println("failed to parse study session duration", err)
190
+
htmx.HxOobUpdate(w, "create-study-session-msg", "Failed to create study session, try again later.")
191
+
return
192
+
}
193
+
194
+
languageCode := r.FormValue("language_code")
195
+
language, ok := db.Languages[db.LanguageCode(languageCode)]
196
+
if !ok {
197
+
log.Printf("failed to find language '%s'", language.Code)
198
+
htmx.HxOobUpdate(w, "create-study-session-msg", "Failed to create study session, try again later.")
199
+
return
200
+
}
201
+
202
+
activityName := r.FormValue("activity_name")
203
+
activity, ok := db.Activities[activityName]
204
+
if !ok {
205
+
log.Println("failed to find activity '%s'", activity.Name)
206
+
htmx.HxOobUpdate(w, "create-study-session-msg", "Failed to create study session, try again later.")
207
+
return
208
+
}
209
+
210
+
createdAt := time.Now().Format(time.RFC3339)
211
+
rkey := web.TID()
212
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
213
+
Collection: "app.yoten.feed.session",
214
+
Repo: user.Did,
215
+
Rkey: rkey,
216
+
Record: &lexutil.LexiconTypeDecoder{
217
+
Val: &yoten.FeedSession{
218
+
Description: &description,
219
+
ActivityName: activityName,
220
+
Date: date.Format(time.RFC3339),
221
+
Duration: db.ToPtr(strconv.FormatInt(int64(duration.Seconds()), 10)),
222
+
LanguageCode: languageCode,
223
+
CreatedAt: createdAt,
224
+
}},
225
+
})
226
+
if err != nil {
227
+
log.Println("failed to create study session record", err)
228
+
htmx.HxOobUpdate(w, "create-study-session-msg", "Failed to create study session, try again later.")
229
+
return
230
+
}
231
+
232
+
session := db.StudySession{
233
+
Did: user.Did,
234
+
Rkey: rkey,
235
+
Description: description,
236
+
Date: date,
237
+
Duration: duration,
238
+
Activity: activity,
239
+
Language: language,
240
+
}
241
+
242
+
tx, err := s.db.BeginTx(r.Context(), nil)
243
+
if err != nil {
244
+
log.Println("failed to start tx")
245
+
htmx.HxOobUpdate(w, "create-study-session-msg", "Failed to create study session, try again later.")
246
+
return
247
+
}
248
+
249
+
err = db.UpsertStudySession(tx, &session, session.Rkey)
250
+
if err != nil {
251
+
// Hopeful ingester updates DB for us.
252
+
log.Println("failed to create study session", err)
253
+
}
254
+
255
+
log.Println("created study session")
256
+
if !s.config.Core.Dev {
257
+
err = s.posthog.Enqueue(posthog.Capture{
258
+
DistinctId: user.Did,
259
+
Event: ph.StudySessionRecordCreatedEvent,
260
+
Properties: posthog.NewProperties().Set("rkey", rkey),
261
+
})
262
+
if err != nil {
263
+
log.Println("failed to enqueue posthog event:", err)
264
+
}
265
+
}
266
+
267
+
htmx.HxRedirect(w, "/")
268
+
}
269
+
}
270
+
271
+
func (s *State) HandleDeleteStudySession(w http.ResponseWriter, r *http.Request) {
272
+
user := s.oauth.GetUser(r)
273
+
274
+
client, err := s.oauth.AuthorizedClient(r)
275
+
if err != nil {
276
+
log.Println("failed to get authorized client", err)
277
+
htmx.HxOobUpdate(w, "update-study-session-msg", "Failed to delete study session, try again later.")
278
+
return
279
+
}
280
+
281
+
switch r.Method {
282
+
case http.MethodDelete:
283
+
err := r.ParseForm()
284
+
if err != nil {
285
+
log.Println("failed to parse study session delete form", err)
286
+
htmx.HxOobUpdate(w, "update-study-session-msg", "Failed to delete study session, try again later.")
287
+
return
288
+
}
289
+
290
+
rkey := r.FormValue("rkey")
291
+
session, err := db.GetStudySessionByRkey(s.db, user.Did, rkey)
292
+
if err != nil {
293
+
log.Println("failed to get study session from db", err)
294
+
htmx.HxOobUpdate(w, "update-study-session-msg", "Failed to delete study session, try again later.")
295
+
return
296
+
}
297
+
298
+
if user.Did != session.Did {
299
+
log.Println("failed to delete study session: user does not own record")
300
+
htmx.HxOobUpdate(w, "update-study-session-msg", "Failed to delete study session, try again later.")
301
+
return
302
+
}
303
+
304
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
305
+
Collection: "app.yoten.feed.session",
306
+
Repo: user.Did,
307
+
Rkey: session.Rkey,
308
+
})
309
+
if err != nil {
310
+
log.Println("failed to delete study session from PDS", err)
311
+
htmx.HxOobUpdate(w, "update-study-session-msg", "Failed to delete study session, try again later.")
312
+
return
313
+
}
314
+
315
+
err = db.DeleteStudySessionByRkey(s.db, user.Did, session.Rkey)
316
+
if err != nil {
317
+
// Hopeful ingester updates DB for us.
318
+
log.Println("failed to delete study session", err)
319
+
}
320
+
321
+
log.Println("deleted study session")
322
+
if !s.config.Core.Dev {
323
+
err = s.posthog.Enqueue(posthog.Capture{
324
+
DistinctId: user.Did,
325
+
Event: ph.StudySessionRecordDeletedEvent,
326
+
Properties: posthog.NewProperties().Set("rkey", session.Rkey),
327
+
})
328
+
if err != nil {
329
+
log.Println("failed to enqueue posthog event:", err)
330
+
}
331
+
}
332
+
333
+
htmx.HxRedirect(w, "/")
334
+
}
335
+
}
+11
internal/web/tid.go
+11
internal/web/tid.go
+57
internal/web/utils.go
+57
internal/web/utils.go
···
1
+
package web
2
+
3
+
import (
4
+
"fmt"
5
+
"strconv"
6
+
"strings"
7
+
"time"
8
+
)
9
+
10
+
func ParseDurationHHMMSS(durationStr string) (time.Duration, error) {
11
+
parts := strings.Split(durationStr, ":")
12
+
if len(parts) != 3 {
13
+
return 0, fmt.Errorf("invalid duration format: expected HH:MM:SS, got %s", durationStr)
14
+
}
15
+
16
+
hours, err := strconv.Atoi(parts[0])
17
+
if err != nil {
18
+
return 0, fmt.Errorf("invalid hours: %v", err)
19
+
}
20
+
21
+
minutes, err := strconv.Atoi(parts[1])
22
+
if err != nil {
23
+
return 0, fmt.Errorf("invalid minutes: %v", err)
24
+
}
25
+
26
+
seconds, err := strconv.Atoi(parts[2])
27
+
if err != nil {
28
+
return 0, fmt.Errorf("invalid seconds: %v", err)
29
+
}
30
+
31
+
if minutes < 0 || minutes >= 60 {
32
+
return 0, fmt.Errorf("minutes out of range (0-59): %d", minutes)
33
+
}
34
+
if seconds < 0 || seconds >= 60 {
35
+
return 0, fmt.Errorf("seconds out of range (0-59): %d", seconds)
36
+
}
37
+
38
+
duration := time.Duration(hours)*time.Hour +
39
+
time.Duration(minutes)*time.Minute +
40
+
time.Duration(seconds)*time.Second
41
+
42
+
return duration, nil
43
+
}
44
+
45
+
func FormatDurationHHMMSS(d time.Duration) string {
46
+
d = d.Truncate(time.Second)
47
+
48
+
hours := d / time.Hour
49
+
d -= hours * time.Hour
50
+
51
+
minutes := d / time.Minute
52
+
d -= minutes * time.Minute
53
+
54
+
seconds := d / time.Second
55
+
56
+
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds)
57
+
}
+9
internal/web/xrpcclient/xrpc.go
+9
internal/web/xrpcclient/xrpc.go
···
44
44
45
45
return &out, nil
46
46
}
47
+
48
+
func (c *Client) RepoDeleteRecord(ctx context.Context, input *atproto.RepoDeleteRecord_Input) (*atproto.RepoDeleteRecord_Output, error) {
49
+
var out atproto.RepoDeleteRecord_Output
50
+
if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.deleteRecord", nil, input, &out); err != nil {
51
+
return nil, err
52
+
}
53
+
54
+
return &out, nil
55
+
}
+51
lexicons/feed/session.json
+51
lexicons/feed/session.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "app.yoten.feed.session",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"description": "A declaration of a Yōten study session.",
10
+
"key": "tid",
11
+
"record": {
12
+
"type": "object",
13
+
"required": ["activityName", "languageCode", "date", "createdAt"],
14
+
"properties": {
15
+
"description": {
16
+
"type": "string",
17
+
"description": "Free-form description for the study session.",
18
+
"maxGraphemes": 64,
19
+
"maxLength": 640
20
+
},
21
+
"activityName": {
22
+
"type": "string",
23
+
"description": "A key used to lookup activity type details",
24
+
"maxGraphemes": 64,
25
+
"maxLength": 640
26
+
},
27
+
"languageCode": {
28
+
"type": "string",
29
+
"description": "An ISO 639-1 two-letter language code (e.g., 'en', 'es', 'ko').",
30
+
"minLength": 2,
31
+
"maxLength": 2
32
+
},
33
+
"duration": {
34
+
"type": "string",
35
+
"description": "The duration of the study session in seconds",
36
+
"format": "datetime"
37
+
},
38
+
"date": {
39
+
"type": "string",
40
+
"format": "datetime"
41
+
},
42
+
"createdAt": {
43
+
"type": "string",
44
+
"format": "datetime"
45
+
}
46
+
}
47
+
}
48
+
}
49
+
}
50
+
}
51
+