Yōten: A social tracker for your language learning journey built on the atproto.

feat(feed.session): add app.yoten.feed.session lexicon and logic to parse and save from db and jetstream

brookjeynes.dev a5980b1c c24d8666

verified
+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
··· 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
··· 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
··· 6 6 7 7 "github.com/bluesky-social/indigo/mst" 8 8 cbg "github.com/whyrusleeping/cbor-gen" 9 + 9 10 "yoten.app/api/yoten" 10 11 ) 11 12 ··· 21 22 22 23 yotenTypes := []any{ 23 24 yoten.ActorProfile{}, 25 + yoten.FeedSession{}, 24 26 } 25 27 26 28 for name, rt := range AllLexTypes() {
+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
··· 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
··· 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
··· 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
··· 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
··· 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 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 1 + package web 2 + 3 + import ( 4 + "github.com/bluesky-social/indigo/atproto/syntax" 5 + ) 6 + 7 + var c syntax.TIDClock = syntax.NewTIDClock(0) 8 + 9 + func TID() string { 10 + return c.Next().String() 11 + }
+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
··· 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
··· 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 +